6effc;g&JhLiNjckmulwmxnopqrstu!=O)*T +):g;wB'2'@'ca'ܴ''z'*'''B'!'0'E'['p''d'''H''}'d'5'M'f'?z'''?*;**f*+*****_++Z+V++++ +0!+o$ +#- +4 +7 +IA +H+K+O+W+`+c+Rh+v+V++{+*+G++e +!+x"+#+$+%+&+_'+](+H)+z*+++,+%-+$.+`?+@+A+'I+N J+(R K+T L+[ M+[ N+\ O+ }+o[ ~+[ +a +d +q +Ó +r +I0 +u + +9 + +M +4 +, +0+=K+u+#+r++'+-+2+X9+`:+;+ >+_+_j+ ++++++o+a+գ+ʥ+Ħ+:++++;+t++++a++@++*+>+@+C+aO+P+GZ+]+++++2+]++_+P+l++3+{+ ++++W+`+b+g+5h+l+‡+*++Z++?+j+^++K+++P+ +x,e,u ,[!,",#,%$,e%,Vh&,k',w(,),*,+,a,,-,.,/,0, "1,*"2,3"3,np"4,p"5,C"7,C"8,S"9, ":,";,,"<,"`,^"a,"n,"o,^"p,d"q,"r,#s,K#t,%#u,#v,#w,6#x,#y, $z,p${,v%|,n}%},%~,%,ԛ%,%,%,%,%,%,z&,&, &,*&,y,&,-&,2&,dz&,մ&,&,&',j8',A',E', M',W',',(,(,(,-,-,@-,-,-,-,-,-,-,r-,-,-,|-,\.(-$.+-E.,-H.--sK..-./-$O/0-}t/1-ؾ/2-/3-j/6-/7-`08-09-0:-L0;-0<-0=-0>-0?-0@-F0A-0B-0C-0D--0E-0g-0h-0i-0j-v0k-c1l-%1m-1n-1o-e2p-22q-B2r-@e2s-'2t-2u-2v-2w-2x-o2y-2z-2{-12|-2}-2~-2-2-2-2-3- 3-3-v3-*3-e53->3-{N3-S3-[3-a3-n3- 3-I3-Ŕ3-3-3-{3-3- 4-R4-݂4-4-4-4-5- 5-T5-5-6-6-df6-ʦ6-6-6-i)7-YD7-LG7-M7- V7-X7-Z7-i7-n7-x7-z7-c7-7-7-V7-7-7-7-7-B7-7-7".7T.8U.8V.6'8|.(8}.P9.g9.9.V:. :.s:.1;.i;.:';.(;.[N;../O>// >0/ >1/>N/>O/>P/->b/0>c/>d/R?e/^@@`1~@a1@b1@c1@d1b@e1d@f1@~1@1@1@1B@1@1A?uA?C?C?#C?,C?d4C?P5C ?)BC ?GC ?qC ?x!E ?7E?`9E?>HE?JE?.NE?VE?f_E?hE?yE? E?܏E?bE?ʗE?àE?E%?G&?G'?G(?FG)?G*?G+?G0??oH??xH@?L]IA?;IB?6IC?`ID?ƛJE?yJF?gJG?7JH?JI?JJ?JK?XJL?JM?JN?(JO?}JP?JQ?cJR? KS?KT?KOKOKOKO'KO,KO6KO8KOG9KO:KO;KO=KO CKOTKO\KO`KOcKOlKOKOKO͓KOKO|KOKOKO6KFPKGP*L_6U_|U_)U_U_U_U_TU_CU_U_U_lU_(V_V_V_\V_V_q'V_QV_TZV_vV_{V_V_V_zV_W_W_wW_2W_yW_W_W_/W_W_W_ X_8X_GX_fX_X_!X_X_pX_X_Y_Y_+Y_`Y_&Y_ʴY_Y_[Y_Y_Y_Z_+Z_[5Z_;Z_,>Z_rJZ_^Z_znZ_wZ_yZ_Z_Z_eZ_Z_Z`Z|`uZ}`yZ~`UZ`*Z`Z`[`H[8cU2[9c|A[:c5E[;cJ[k_?kQ`@k9aAkaBkaCk;aDkaEkaFk@aGk/aHkaIkaJkaKkbaLk)aMkaNkaOkeaPk aQkaRkaSkaTkBaUkaVkaWkaXkaYkaZka[k0a\ka]ka^ka_kpa`kaakLabkackadkyaekZafkagk+ahkBaikajkakkalk>amk=ankaokapkaqkarkaskatkaukWbvk^bwkbxkbykbzkb{k b|kb}kJb~kbkbk:bkbk7bkbkobkbkbkbkbkWbkbk%bk="bkh'bk'bk(bk)bk)bk*bk+bkn,bk1-bk.bk.bkt/bk0bk0bk1bk2bkb4bkt5bk6bk7bl8bl9bl:bl=bl?blBblCb lEb lIb lRb lVb lbWblZbl,\blT`blcblfbl`jblpbl.rbl{blq~blblbl?blblbl blbl؜b lIb!lb"lb#ljb$lb%lb&lb'l۟b(l6b)lb*l7b+lb,lb-ljb.lb/l1b0lb1lݣb2l6b3lb4lդb5l&b6lub7lɥb8lb9lqb:lݦb;l,blݫb?lFb@lbAlbBlbClbDlhbElbFlsbGlIbHlybIl9bJlbKlbLljbMlbNlbOlcPlqcQlcRlB0cSlel@eloKelKelNelIRelTel"Vel=WelWel\el\el-_elbelcelfelnelpeluqelselwelxel|el}elel)ellelelelel4elelelQel|el٫elBelӲememضemem׹emememememe mWe me m:e m+e memaemememgem4fm3fm- fmfmtfmfm0fmt2fm/8fm?fmAfmCfmeFfmKf maf!mstf"m4f#mVf$mf%m"g&m7g'mDg(mPg)mZg*m\g+mfhg,mjg-mPg.mKg/mݮg0mg1mg2mg3mҸg4mg5mdg6mg7mg8mg9mbg:m$g;mgmg?mg@mgAmhBmu hCm hDm hEm hFmhGmahHmhImB!hJm"hKm&hLm+hMm0hNm1hOmF6o$7oKh8oi9o:oNĞ;oso?o@oAofBoTCoPDo Eo>Fo|GoHoIoJoKoELoyMoNoOoYPoJ+Qo.Rom/SoC1To1Uoo2Vo2WoZXoe[Yo\Zo\[o]\od^]oe_^o__o[`ao9aboaco.do䢼fogovho㶼ioPjo kolomorмnooopov5qoK8ro@>soDtoHFuoKvo`PwoIRxoRyoSzoOU{oU|oV}o W~oWo[oW_o_obododo jokjojomodqoCooooooop]ֽ0,k+`o"eo%{ "services": [ { "display_name": "File Service", "name": "file", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ] }, "provides": { "file:leveldb": [ "leveldb::mojom::LevelDBService" ], "file:filesystem": [ "file::mojom::FileSystem" ] } } } } ], "display_name": "Content (browser process)", "name": "content_browser", "interface_provider_specs": { "navigation:shared_worker": { "provides": { "renderer": [ "blink::mojom::BudgetService", "blink::mojom::LockManager", "blink::mojom::NotificationService", "blink::mojom::PermissionService", "blink::mojom::WebSocket", "payments::mojom::PaymentManager", "shape_detection::mojom::BarcodeDetection", "shape_detection::mojom::FaceDetectionProvider", "shape_detection::mojom::TextDetection" ] } }, "navigation:dedicated_worker": { "provides": { "renderer": [ "blink::mojom::BudgetService", "blink::mojom::LockManager", "blink::mojom::NotificationService", "blink::mojom::PermissionService", "payments::mojom::PaymentManager", "shape_detection::mojom::BarcodeDetection", "shape_detection::mojom::FaceDetectionProvider", "shape_detection::mojom::TextDetection" ] } }, "service_manager:connector": { "requires": { "data_decoder": [ "image_decoder", "json_parser", "xml_parser" ], "network": [ "network_service", "test", "url_loader" ], "content_renderer": [ "browser" ], "resource_coordinator": [ "coordination_unit", "coordination_unit_introspector", "service_callbacks", "page_signal", "tracing" ], "cdm": [ "media:cdm" ], "*": [ "app" ], "shape_detection": [ "barcode_detection", "face_detection", "text_detection" ], "content_plugin": [ "browser" ], "patch_service": [ "patch_file" ], "metrics": [ "url_keyed_metrics" ], "content_utility": [ "browser" ], "service_manager": [ "service_manager:client_process", "service_manager:instance_name", "service_manager:service_manager", "service_manager:user_id" ], "ui": [ "arc_manager", "display_output_protection", "video_detector" ], "file": [ "file:filesystem", "file:leveldb" ], "media": [ "media:media" ], "device": [ "device:battery_monitor", "device:generic_sensor", "device:geolocation", "device:hid", "device:input_service", "device:nfc", "device:serial", "device:vibration", "device:wake_lock" ], "content_browser": [ "geolocation_config" ], "video_capture": [ "capture", "tests" ], "content_gpu": [ "browser" ] }, "provides": { "field_trials": [ "content::mojom::FieldTrialRecorder" ], "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ], "plugin": [ "discardable_memory::mojom::DiscardableSharedMemoryManager", "ui::mojom::Gpu" ], "app": [ "discardable_memory::mojom::DiscardableSharedMemoryManager", "memory_instrumentation::mojom::Coordinator" ], "renderer": [ "blink::mojom::BackgroundSyncService", "blink::mojom::BlobRegistry", "blink::mojom::BroadcastChannelProvider", "blink::mojom::ClipboardHost", "blink::mojom::LockManager", "blink::mojom::Hyphenation", "blink::mojom::MimeRegistry", "blink::mojom::OffscreenCanvasProvider", "blink::mojom::ReportingServiceProxy", "blink::mojom::WebDatabaseHost", "content::mojom::AppCacheBackend", "content::mojom::ClipboardHost", "content::mojom::FieldTrialRecorder", "content::mojom::FileUtilitiesHost", "content::mojom::FrameSinkProvider", "content::mojom::MediaStreamDispatcherHost", "content::mojom::MemoryCoordinatorHandle", "content::mojom::PushMessaging", "content::mojom::QuotaDispatcherHost", "content::mojom::RendererHost", "content::mojom::ReportingServiceProxy", "content::mojom::ServiceWorkerDispatcherHost", "content::mojom::StoragePartitionService", "content::mojom::URLLoaderFactory", "content::mojom::VideoCaptureHost", "content::mojom::WorkerURLLoaderFactoryProvider", "device::mojom::BatteryMonitor", "device::mojom::GamepadHapticsManager", "device::mojom::GamepadMonitor", "discardable_memory::mojom::DiscardableSharedMemoryManager", "media::mojom::VideoDecodePerfHistory", "memory_coordinator::mojom::MemoryCoordinatorHandle", "metrics::mojom::SingleSampleMetricsProvider", "resource_coordinator::mojom::ProcessCoordinationUnit", "ui::mojom::Gpu", "viz::mojom::SharedBitmapAllocationNotifier", "viz::mojom::CompositingModeReporter" ], "font_cache": [ "content::mojom::FontCacheWin" ], "geolocation_config": [ "device::mojom::GeolocationConfig" ], "gpu": [ "media::mojom::AndroidOverlayProvider" ] } }, "navigation:service_worker": { "provides": { "renderer": [ "blink::mojom::BackgroundFetchService", "blink::mojom::BudgetService", "blink::mojom::LockManager", "blink::mojom::NotificationService", "blink::mojom::PermissionService", "blink::mojom::WebSocket", "network::mojom::RestrictedCookieManager", "payments::mojom::PaymentManager", "shape_detection::mojom::BarcodeDetection", "shape_detection::mojom::FaceDetectionProvider", "shape_detection::mojom::TextDetection" ] } }, "navigation:frame": { "requires": { "content_renderer": [ "browser" ] }, "provides": { "renderer": [ "autofill::mojom::AutofillDriver", "autofill::mojom::PasswordManagerDriver", "blink::mojom::BackgroundFetchService", "blink::mojom::BudgetService", "blink::mojom::ColorChooserFactory", "blink::mojom::DedicatedWorkerFactory", "blink::mojom::LockManager", "blink::mojom::GeolocationService", "blink::mojom::InsecureInputService", "blink::mojom::KeyboardLockService", "blink::mojom::MediaDevicesDispatcherHost", "blink::mojom::MediaSessionService", "blink::mojom::NotificationService", "blink::mojom::PermissionService", "blink::mojom::PresentationService", "blink::mojom::TextSuggestionHost", "blink::mojom::WebBluetoothService", "blink::mojom::WebSocket", "content::mojom::BrowserTarget", "content::mojom::InputInjector", "content::mojom::RendererAudioOutputStreamFactory", "content::mojom::SharedWorkerConnector", "device::mojom::Geolocation", "device::mojom::NFC", "device::mojom::SensorProvider", "device::mojom::UsbChooserService", "device::mojom::UsbDeviceManager", "device::mojom::VibrationManager", "device::mojom::VRService", "device::mojom::WakeLock", "device::mojom::UsbDeviceManager", "device::mojom::VRService", "discardable_memory::mojom::DiscardableSharedMemoryManager", "media::mojom::ImageCapture", "media::mojom::InterfaceFactory", "media::mojom::MediaMetricsProvider", "media::mojom::RemoterFactory", "media::mojom::Renderer", "network::mojom::RestrictedCookieManager", "password_manager::mojom::CredentialManager", "payments::mojom::PaymentManager", "payments::mojom::PaymentRequest", "password_manager::mojom::CredentialManager", "resource_coordinator::mojom::FrameCoordinationUnit", "shape_detection::mojom::BarcodeDetection", "shape_detection::mojom::FaceDetectionProvider", "shape_detection::mojom::TextDetection", "ui::mojom::Gpu", "webauth::mojom::Authenticator" ] } } } }{ "services": [ { "display_name": "Identity Service", "name": "identity", "interface_provider_specs": { "service_manager:connector": { "provides": { "identity_manager": [ "identity::mojom::IdentityManager" ] } } } }, { "display_name": "Preferences", "name": "preferences", "interface_provider_specs": { "service_manager:connector": { "requires": {}, "provides": { "pref_client": [ "prefs::mojom::PrefStoreConnector" ] } } } } ], "display_name": "Chrome", "name": "content_browser", "interface_provider_specs": { "navigation:shared_worker": { "provides": { "renderer": [ "blink::mojom::BudgetService" ] } }, "navigation:dedicated_worker": { "provides": { "renderer": [ "blink::mojom::BudgetService" ] } }, "service_manager:connector": { "requires": { "removable_storage_writer": [ "removable_storage_writer" ], "local_state": [ "pref_client" ], "wifi_util_win": [ "wifi_credentials" ], "nacl_loader": [ "browser" ], "accessibility_autoclick": [ "ash:autoclick" ], "profile_import": [ "import" ], "chrome_printing": [ "converter" ], "preferences": [ "pref_client", "pref_control" ], "chrome": [ "input_device_controller" ], "ash_pref_connector": [ "pref_connector" ], "nacl_broker": [ "browser" ], "profiling": [ "profiling" ], "media_gallery_util": [ "parse_media" ], "device": [ "device:fingerprint", "device:geolocation_control", "device:ip_geolocator" ], "content_browser": [ "profiling_client" ], "ash": [ "system_ui", "test", "display" ], "identity": [ "identity_manager" ], "pdf_compositor": [ "compositor" ], "patch": [ "patch_file" ], "ui": [ "display_dev", "ime_registrar", "input_device_controller", "window_manager" ], "proxy_resolver": [ "factory" ], "file_util": [ "analyze_archive", "zip_file" ], "util_win": [ "shell_util_win" ] }, "provides": { "gpu": [ "metrics::mojom::CallStackProfileCollector" ], "profiling_client": [ "profiling::mojom::ProfilingClient" ], "renderer": [ "autofill::mojom::AutofillDriver", "autofill::mojom::PasswordManagerDriver", "chrome::mojom::CacheStatsRecorder", "chrome::mojom::NetBenchmarking", "extensions::StashService", "metrics::mojom::LeakDetector", "mojom::ModuleEventSink", "rappor::mojom::RapporRecorder", "safe_browsing::mojom::SafeBrowsing", "translate::mojom::ContentTranslateDriver" ], "ime:ime_driver": [] } }, "navigation:service_worker": { "provides": { "renderer": [ "blink::mojom::BudgetService" ] } }, "navigation:frame": { "provides": { "renderer": [ "autofill::mojom::AutofillDriver", "autofill::mojom::PasswordManagerDriver", "blink::mojom::BudgetService", "blink::mojom::InstalledAppProvider", "blink::mojom::MediaDownloadInProductHelp", "blink::mojom::ShareService", "blink::mojom::TextSuggestionHost", "chrome::mojom::OpenSearchDocumentDescriptionHandler", "chrome::mojom::PrerenderCanceler", "contextual_search::mojom::ContextualSearchJsApiService", "dom_distiller::mojom::DistillabilityService", "dom_distiller::mojom::DistillerJavaScriptService", "extensions::KeepAlive", "extensions::mime_handler::MimeHandlerService", "extensions::mojom::InlineInstall", "media_router::mojom::MediaRouter", "page_load_metrics::mojom::PageLoadMetrics", "password_manager::mojom::CredentialManager", "translate::mojom::ContentTranslateDriver", "media::mojom::MediaEngagementScoreDetailsProvider", "mojom::BluetoothInternalsHandler", "mojom::DiscardsDetailsProvider", "mojom::InterventionsInternalsPageHandler", "mojom::OmniboxPageHandler", "mojom::PluginsPageHandler", "mojom::SiteEngagementDetailsProvider", "mojom::UsbInternalsPageHandler" ] } } } }{ "display_name": "Content (GPU process)", "name": "content_gpu", "interface_provider_specs": { "service_manager:connector": { "requires": { "device": [ "device:power_monitor" ], "content_browser": [ "field_trials", "gpu" ], "*": [ "app" ], "metrics": [ "url_keyed_metrics" ] }, "provides": { "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ], "browser": [ "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcher", "content::mojom::ChildHistogramFetcherFactory", "IPC::mojom::ChannelBootstrap", "service_manager::mojom::ServiceFactory", "viz::mojom::CompositingModeReporter", "viz::mojom::VizMain" ] } } } }{ "name": "content_gpu", "interface_provider_specs": { "service_manager:connector": { "provides": { "browser": [ "arc::mojom::ProtectedBufferManager", "arc::mojom::VideoDecodeAccelerator", "arc::mojom::VideoDecodeClient", "arc::mojom::VideoEncodeAccelerator", "arc::mojom::VideoEncodeClient", "chrome::mojom::ResourceUsageReporter", "profiling::mojom::ProfilingClient" ] } } } }{ "services": [ { "display_name": "Network Service", "sandbox_type": "network", "name": "network", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "test": [ "content::mojom::NetworkServiceTest" ], "network_service": [ "content::mojom::NetworkService" ], "url_loader": [ "content::mojom::URLLoaderFactory" ] } } } }, { "display_name": "Content Decryption Module Service", "sandbox_type": "cdm", "name": "cdm", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ] }, "provides": { "media:cdm": [ "media::mojom::CdmService" ] } } } }, { "display_name": "Media Service", "name": "media", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ] }, "provides": { "media:media": [ "media::mojom::MediaService" ] } } } }, { "display_name": "Data Decoder Service", "name": "data_decoder", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "json_parser": [ "data_decoder::mojom::JsonParser" ], "xml_parser": [ "data_decoder::mojom::XmlParser" ], "image_decoder": [ "data_decoder::mojom::ImageDecoder" ] } } } }, { "display_name": "Device Service", "name": "device", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "device:input_service": [ "device::mojom::InputDeviceManager" ], "device:screen_orientation": [ "device::mojom::ScreenOrientationListener" ], "device:ip_geolocator": [ "device::mojom::PublicIpAddressGeolocationProvider" ], "device:power_monitor": [ "device::mojom::PowerMonitor" ], "device:nfc": [ "device::mojom::NFCProvider" ], "device:wake_lock": [ "device::mojom::WakeLockProvider" ], "device:serial": [ "device::mojom::SerialDeviceEnumerator", "device::mojom::SerialIoHandler" ], "device:fingerprint": [ "device::mojom::Fingerprint" ], "device:geolocation_control": [ "device::mojom::GeolocationControl" ], "device:time_zone_monitor": [ "device::mojom::TimeZoneMonitor" ], "device:geolocation": [ "device::mojom::GeolocationContext" ], "device:battery_monitor": [ "device::mojom::BatteryMonitor" ], "device:hid": [ "device::mojom::HidManager" ], "device:vibration": [ "device::mojom::VibrationManager" ], "device:generic_sensor": [ "device::mojom::SensorProvider" ] } } } }, { "display_name": "Metrics Service", "name": "metrics", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "url_keyed_metrics": [ "ukm::mojom::UkmRecorderInterface" ] } } } }, { "display_name": "Global Resource Coordinator", "name": "resource_coordinator", "interface_provider_specs": { "service_manager:connector": { "requires": { "metrics": [ "url_keyed_metrics" ], "service_manager": [ "service_manager:all_users", "service_manager:service_manager" ] }, "provides": { "tests": [ "*" ], "page_signal": [ "resource_coordinator::mojom::PageSignalGenerator" ], "coordination_unit_introspector": [ "resource_coordinator::mojom::CoordinationUnitIntrospector" ], "app": [ "memory_instrumentation::mojom::Coordinator", "tracing::mojom::AgentRegistry" ], "tracing": [ "tracing::mojom::Coordinator" ], "coordination_unit": [ "resource_coordinator::mojom::CoordinationUnitProvider" ] } } } }, { "display_name": "Shape Detection Service", "name": "shape_detection", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "face_detection": [ "shape_detection::mojom::FaceDetectionProvider" ], "barcode_detection": [ "shape_detection::mojom::BarcodeDetection" ], "text_detection": [ "shape_detection::mojom::TextDetection" ] } } } }, { "display_name": "Video Capture", "sandbox_type": "none", "name": "video_capture", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "capture": [ "video_capture::mojom::DeviceFactoryProvider" ], "tests": [ "video_capture::mojom::DeviceFactoryProvider", "video_capture::mojom::TestingControls" ] } } } }, { "sandbox_type": "none", "display_name": "Visuals Service", "name": "viz", "interface_provider_specs": { "service_manager:connector": { "requires": { "catalog": [ "directory" ], "*": [ "app" ], "ui": [ "ozone" ], "service_manager": [ "service_manager:all_users" ] }, "provides": { "viz_host": [ "viz::mojom::VizMain" ] } } } } ], "display_name": "Content Packaged Services", "name": "content_packaged_services", "interface_provider_specs": { "service_manager:connector": { "requires": { "content_browser": [], "*": [ "app" ], "service_manager": [ "service_manager:client_process", "service_manager:singleton", "service_manager:user_id" ] }, "provides": { "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ] } } } }{ "services": [ { "display_name": "Chrome", "name": "chrome", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "input_device_controller": [ "ui::mojom::InputDeviceController" ], "renderer": [ "spellcheck::mojom::SpellCheckHost", "spellcheck::mojom::SpellCheckPanelHost", "startup_metric_utils::mojom::StartupMetricHost" ], "mash:launchable": [ "mash::mojom::Launchable" ] } } } }, { "display_name": "Profiling Service", "sandbox_type": "profiling", "name": "profiling", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ], "service_manager": [ "service_manager:all_users" ] }, "provides": { "profiling": [ "profiling::mojom::ProfilingService" ] } } } }, { "display_name": "Patch Service", "sandbox_type": "utility", "name": "patch_service", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "patch_file": [ "patch::mojom::FilePatcher" ] } } } }, { "display_name": "Chrome File Utilities", "sandbox_type": "utility", "name": "file_util", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "zip_file": [ "chrome::mojom::ZipFileCreator" ], "analyze_archive": [ "chrome::mojom::SafeArchiveAnalyzer" ] } } } }, { "display_name": "Proxy resolver", "name": "proxy_resolver", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "factory": [ "proxy_resolver::mojom::ProxyResolverFactory" ] } } } }, { "display_name": "Local state preferences", "name": "local_state", "interface_provider_specs": { "service_manager:connector": { "requires": {}, "provides": { "pref_client": [ "prefs::mojom::PrefStoreConnector" ] } } } }, { "display_name": "Printing", "sandbox_type": "utility", "name": "chrome_printing", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "converter": [ "printing::mojom::PdfToEmfConverterFactory", "printing::mojom::PdfToPwgRasterConverter" ] } } } }, { "display_name": "PDF Compositor Service", "sandbox_type": "pdf_compositor", "name": "pdf_compositor", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ], "service_manager": [ "service_manager:all_users" ] }, "provides": { "compositor": [ "printing::mojom::PdfCompositor" ] } } } }, { "display_name": "Chrome Media Gallery Utilities", "sandbox_type": "utility", "name": "media_gallery_util", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "parse_media": [ "chrome::mojom::MediaParser" ] } } } }, { "display_name": "Removable Storage Writer", "sandbox_type": "none_and_elevated", "name": "removable_storage_writer", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "removable_storage_writer": [ "chrome::mojom::RemovableStorageWriter" ] } } } }, { "display_name": "Windows Utilities", "sandbox_type": "none", "name": "util_win", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "shell_util_win": [ "chrome::mojom::ShellUtilWin" ] } } } }, { "display_name": "Windows WiFi Utilities", "sandbox_type": "none_and_elevated", "name": "wifi_util_win", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "wifi_credentials": [ "chrome::mojom::WiFiCredentialsGetter" ] } } } }, { "display_name": "Profile Import", "sandbox_type": "none", "name": "profile_import", "interface_provider_specs": { "service_manager:connector": { "requires": { "service_manager": [ "service_manager:all_users" ] }, "provides": { "import": [ "chrome::mojom::ProfileImport" ] } } } } ], "display_name": "Chrome Packaged Services", "name": "content_packaged_services", "interface_provider_specs": {} }{ "display_name": "Content (plugin process)", "name": "content_plugin", "interface_provider_specs": { "service_manager:connector": { "requires": { "device": [ "device:power_monitor" ], "content_browser": [ "field_trials", "font_cache", "plugin" ], "*": [ "app" ], "ui": [ "discardable_memory" ] }, "provides": { "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ], "browser": [ "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcher", "content::mojom::ChildHistogramFetcherFactory", "IPC::mojom::ChannelBootstrap" ] } } } }{ "name": "content_plugin", "interface_provider_specs": { "service_manager:connector": { "provides": { "browser": [ "chrome::mojom::ResourceUsageReporter" ] } } } }{ "required_files": { "v8_context_snapshot_data": [ { "path": "v8_context_snapshot.bin", "platform": "linux" } ], "v8_natives_data": [ { "path": "natives_blob.bin", "platform": "linux" }, { "path": "assets/natives_blob.bin", "platform": "android" } ], "v8_snapshot_64_data": [ { "path": "assets/snapshot_blob_64.bin", "platform": "android" } ], "v8_snapshot_data": [ { "path": "snapshot_blob.bin", "platform": "linux" } ], "v8_snapshot_32_data": [ { "path": "assets/snapshot_blob_32.bin", "platform": "android" } ] }, "display_name": "Content (renderer process)", "name": "content_renderer", "interface_provider_specs": { "navigation:shared_worker": { "requires": { "content_browser": [ "renderer" ] } }, "navigation:dedicated_worker": { "requires": { "content_browser": [ "renderer" ] } }, "service_manager:connector": { "requires": { "metrics": [ "url_keyed_metrics" ], "content_browser": [ "field_trials", "renderer" ], "ui": [ "discardable_memory", "gpu_client" ], "*": [ "app" ], "device": [ "device:power_monitor", "device:screen_orientation", "device:time_zone_monitor" ] }, "provides": { "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ], "browser": [ "blink::mojom::OomIntervention", "blink::mojom::WebDatabase", "content::mojom::AppCacheFrontend", "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcher", "content::mojom::ChildHistogramFetcherFactory", "content::mojom::EmbeddedWorkerInstanceClient", "content::mojom::EmbeddedWorkerSetup", "content::mojom::FrameFactory", "content::mojom::RenderWidgetWindowTreeClientFactory", "content::mojom::SharedWorkerFactory", "IPC::mojom::ChannelBootstrap", "visitedlink::mojom::VisitedLinkNotificationSink", "web_cache::mojom::WebCache" ] } }, "navigation:service_worker": { "requires": { "content_browser": [ "renderer" ] } }, "navigation:frame": { "requires": { "content_browser": [ "renderer" ] }, "provides": { "browser": [ "blink::mojom::AppBannerController", "blink::mojom::EngagementClient", "blink::mojom::InstallationService", "blink::mojom::ManifestManager", "blink::mojom::MediaDevicesListener", "blink::mojom::TextSuggestionBackend", "content::mojom::ImageDownloader", "content::mojom::FrameInputHandler", "content::mojom::MediaStreamDispatcher", "content::mojom::Widget", "viz::mojom::InputTargetClient" ] } } } }{ "display_name": "Chrome Render Process", "interface_provider_specs": { "service_manager:connector": { "requires": { "chrome": [ "renderer" ] }, "provides": { "browser": [ "chrome::mojom::ResourceUsageReporter", "chrome::mojom::SearchBouncer", "spellcheck::mojom::SpellChecker", "profiling::mojom::ProfilingClient" ] } }, "navigation:frame": { "provides": { "browser": [ "autofill::mojom::AutofillAgent", "autofill::mojom::PasswordAutofillAgent", "autofill::mojom::PasswordGenerationAgent", "blink::mojom::document_metadata::CopylessPaste", "chrome::mojom::InsecureContentRenderer", "chrome::mojom::ChromeRenderFrame", "dom_distiller::mojom::DistillerPageNotifierService", "extensions::mojom::AppWindow", "spellcheck::mojom::SpellCheckPanel" ] } } } }{ "required_files": { "v8_context_snapshot_data": [ { "path": "v8_context_snapshot.bin", "platform": "linux" } ], "v8_natives_data": [ { "path": "natives_blob.bin", "platform": "linux" }, { "path": "assets/natives_blob.bin", "platform": "android" } ], "v8_snapshot_64_data": [ { "path": "assets/snapshot_blob_64.bin", "platform": "android" } ], "v8_snapshot_data": [ { "path": "snapshot_blob.bin", "platform": "linux" } ], "v8_snapshot_32_data": [ { "path": "assets/snapshot_blob_32.bin", "platform": "android" } ] }, "display_name": "Content (utility process)", "name": "content_utility", "interface_provider_specs": { "service_manager:connector": { "requires": { "device": [ "device:power_monitor", "device:time_zone_monitor" ], "content_browser": [ "field_trials" ], "*": [ "app" ] }, "provides": { "service_manager:service_factory": [ "service_manager::mojom::ServiceFactory" ], "browser": [ "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcher", "content::mojom::ChildHistogramFetcherFactory", "IPC::mojom::ChannelBootstrap", "printing::mojom::PdfToEmfConverterFactory", "printing::mojom::PdfToPwgRasterConverter", "service_manager::mojom::ServiceFactory" ] } } } }{ "name": "content_utility", "interface_provider_specs": { "service_manager:connector": { "provides": { "browser": [ "chrome::mojom::DialDeviceDescriptionParser", "chrome::mojom::ProfileImport", "chrome::mojom::ResourceUsageReporter", "chrome::mojom::ShellHandler", "extensions::mojom::ExtensionUnpacker", "extensions::mojom::ManifestParser", "extensions::mojom::MediaParser", "payments::mojom::PaymentManifestParser", "profiling::mojom::ProfilingClient", "proxy_resolver::mojom::ProxyResolverFactory", "safe_json::mojom::SafeJsonParser" ] } } } }{ "name": "catalog", "display_name": "Application Resolver", "interface_provider_specs": { // NOTE: This manifest is for documentation purposes only. Relevant // capability spec is defined inline in the ServiceManager implementation. // // TODO(rockot): Fix this. We can bake this file into ServiceManager at // build time or something. Same with service:service_manager. "service_manager:connector": { "provides": { "directory": [ "filesystem::mojom::Directory" ], "control": [ "catalog::mojom::CatalogControl" ] }, "requires": { "service_manager": [ "service_manager:all_users" ] } } } } { "name": "nacl_loader", "display_name": "NaCl loader", "interface_provider_specs": { "service_manager:connector": { "provides": { "browser": [ "IPC::mojom::ChannelBootstrap", // NOTE: The interfaces below are not implemented in the nacl_loader // service, but they are requested from all child processes by common // browser-side code. We list them here to make the Service Manager // happy. "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcherFactory" ] } } } } { "name": "nacl_broker", "display_name": "NaCl broker", "interface_provider_specs": { "service_manager:connector": { "provides": { "browser": [ "IPC::mojom::ChannelBootstrap", // NOTE: The interfaces below are not implemented in the nacl_loader // service, but they are requested from all child processes by common // browser-side code. We list them here to make the Service Manager // happy. "content::mojom::Child", "content::mojom::ChildControl", "content::mojom::ChildHistogramFetcherFactory" ] } } } }  #< bin * pdf * rtf * swfD * splE * crx * 001 * 7z4 * ace * arc * arj: * b64 * balz * bhx * bz * bz28 * bzip2 * cab * cpio@ * fat * gz6 * gzip * hfs * hqx * iso * lha< * lpaq1 * lpaq5 * lpaq8 * lzh; * lzma? * mim * ntfs * paq8f * paq8jd * paq8l * paq8o * pea * quad * r00 * r01 * r02 * r03 * r04 * r05 * r06 * r07 * r08 * r09 * r10 * r11 * r12 * r13 * r14 * r15 * r16 * r17 * r18 * r19 * r20 * r21 * r22 * r23 * r24 * r25 * r26 * r27 * r28 * r29 * rar * squashfs * swm * tar9 * taz * tbz * tbz2 * tgz7 * tpz * txz * tz * udf * uu * uue * vhd * vhdx * vmdk * wim= * wrc * xar * xxe * xz5 * z> * zip * zipx * zpaq * class * jar * jnlp * pl * py * pyc * pyw * rb * efi * torrent * btapp * btbtskin * btinstall * btkey * btsearch * msi * msp! * mst" * adeb * adpc * madd * mafe * magf * mamg * maqh * mari * masj * matk * mavl * mawm * mdan * mdbo * mdep * mdtq * mdwr * mdzs * ocxZ * ops[ * paf * pcd\ * pif * plg] * prf^ * prg_ * pst` * docx * docm * dott * dotm * docb * xlsx * xlsm * xltx * xltm * pptx * pptm * potx * ppam * ppsx * sldx * sldm * partial * xrm-ms * rels * svg * xml * xsl * ps1+ * ps1xml, * ps2- * ps2xml. * psc1/ * psc20 * url * website * js * jse * vb * vbe * vbs * vbscript * ws{ * wsc| * wsf3 * wsh} * msh% * msh1& * msh2( * mshxml* * msh1xml' * msh2xml) * ad * appB * applicationF * appref-ms * aspG * asxH * bas# * bat * cfgI * chiJ * chmK * cmdA * com * cplL * crta * dhtml * dhtm * dht * dll * drv * eml * exe * fon * fxpM * gadget * grp * hlpN * hta$ * htm * html * httO * infP * iniQ * insR * inx * isu * ispS * job * lnkT * localU * manifestV * mauW * mht * mhtml * mmcX * mofY * msc * msg * reg * rgs * scf1 * scr * sct2 * search-ms * shbt * shsu * shtml * shtm * sht * sys * u3p * vdx * vsx * vtx * vsdx * vssx * vstx * vsdm * vssm * vstm * vsdv * vsmacrosw * vssx * vsty * vswz * xbap~ * xht * xhtm * xhtml * xnk * cdr * dart * dc42 * diskcopy42 * dmg * dmgpart * dvdr * img * imgpart * ndif * smi * sparsebundle * sparseimage * toast * udif * action * as * cpgz * command * mpkg * pax * workflow * xip * pkg * deb * pet * pup * rpm * slp * out * run * bash * csh * ksh * sh * shar * tcsh * dex * apk *"  *( { "x-version": 41, "google-talk": { "mime_types": [ ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "'Google Talk Plugin' and 'Google Talk Plugin Video Accelerator' use two completely different versioning schemes, so we can't define a minimum version." } ], "name": "Google Talk", "group_name_matcher": "*Google Talk*" }, "java-runtime-environment": { "mime_types": [ "application/x-java-applet", "application/x-java-applet;jpi-version=1.7.0_05", "application/x-java-applet;version=1.1", "application/x-java-applet;version=1.1.1", "application/x-java-applet;version=1.1.2", "application/x-java-applet;version=1.1.3", "application/x-java-applet;version=1.2", "application/x-java-applet;version=1.2.1", "application/x-java-applet;version=1.2.2", "application/x-java-applet;version=1.3", "application/x-java-applet;version=1.3.1", "application/x-java-applet;version=1.4", "application/x-java-applet;version=1.4.1", "application/x-java-applet;version=1.4.2", "application/x-java-applet;version=1.5", "application/x-java-applet;version=1.6", "application/x-java-applet;version=1.7", "application/x-java-bean", "application/x-java-bean;jpi-version=1.7.0_05", "application/x-java-bean;version=1.1", "application/x-java-bean;version=1.1.1", "application/x-java-bean;version=1.1.2", "application/x-java-bean;version=1.1.3", "application/x-java-bean;version=1.2", "application/x-java-bean;version=1.2.1", "application/x-java-bean;version=1.2.2", "application/x-java-bean;version=1.3", "application/x-java-bean;version=1.3.1", "application/x-java-bean;version=1.4", "application/x-java-bean;version=1.4.1", "application/x-java-bean;version=1.4.2", "application/x-java-bean;version=1.5", "application/x-java-bean;version=1.6", "application/x-java-bean;version=1.7", "application/x-java-vm", "application/x-java-vm-npruntime" ], "versions": [ { "version": "10.45", "status": "requires_authorization", "comment": "Java SE 7u45" } ], "lang": "en-US", "name": "Java(TM)", "help_url": "https://support.google.com/chrome/?p=plugin_java", "url": "http://java.com/download", "displayurl": true, "group_name_matcher": "Java*" }, "ibm-java-runtime-environment": { "mime_types": [ "application/x-java-applet", "application/x-java-applet;jpi-version=1.7.0_05", "application/x-java-applet;version=1.1", "application/x-java-applet;version=1.1.1", "application/x-java-applet;version=1.1.2", "application/x-java-applet;version=1.1.3", "application/x-java-applet;version=1.2", "application/x-java-applet;version=1.2.1", "application/x-java-applet;version=1.2.2", "application/x-java-applet;version=1.3", "application/x-java-applet;version=1.3.1", "application/x-java-applet;version=1.4", "application/x-java-applet;version=1.4.1", "application/x-java-applet;version=1.4.2", "application/x-java-applet;version=1.5", "application/x-java-applet;version=1.6", "application/x-java-applet;version=1.7", "application/x-java-bean", "application/x-java-bean;jpi-version=1.7.0_05", "application/x-java-bean;version=1.1", "application/x-java-bean;version=1.1.1", "application/x-java-bean;version=1.1.2", "application/x-java-bean;version=1.1.3", "application/x-java-bean;version=1.2", "application/x-java-bean;version=1.2.1", "application/x-java-bean;version=1.2.2", "application/x-java-bean;version=1.3", "application/x-java-bean;version=1.3.1", "application/x-java-bean;version=1.4", "application/x-java-bean;version=1.4.1", "application/x-java-bean;version=1.4.2", "application/x-java-bean;version=1.5", "application/x-java-bean;version=1.6", "application/x-java-bean;version=1.7", "application/x-java-vm", "application/x-java-vm-npruntime" ], "versions": [ ], "name": "IBM Java", "group_name_matcher": "*IBM*Java*" }, "realplayer": { "mime_types": [ "audio/vnd.rn-realaudio", "video/vnd.rn-realvideo", "audio/x-pn-realaudio-plugin", "audio/x-pn-realaudio" ], "versions": [ { "version": "15.0.2.71", "status": "requires_authorization", "reference": "http://service.real.com/realplayer/security/02062012_player/en/" } ], "lang": "en-US", "name": "RealPlayer", "help_url": "https://support.google.com/chrome/?p=plugin_real", "url": "http://forms.real.com/real/realone/download.html?type=rpsp_us", "group_name_matcher": "*RealPlayer*" }, "adobe-flash-player": { "mime_types": [ "application/futuresplash", "application/x-shockwave-flash" ], "versions": [ { "version": "27.0.0.187", "status": "requires_authorization", "reference": "https://helpx.adobe.com/security/products/flash-player/apsb17-33.html" } ], "lang": "en-US", "name": "Adobe Flash Player", "help_url": "https://support.google.com/chrome/?p=plugin_flash", "url": "https://support.google.com/chrome/answer/6258784", "displayurl": true, "group_name_matcher": "*Shockwave Flash*" }, "adobe-shockwave": { "mime_types": [ "application/x-director" ], "versions": [ { "version": "12.1.0.150", "status": "requires_authorization", "reference": "https://helpx.adobe.com/security/products/shockwave/apsb14-10.html" } ], "lang": "en-US", "name": "Adobe Shockwave Player", "help_url": "https://support.google.com/chrome/?p=plugin_shockwave", "url": "http://fpdownload.macromedia.com/get/shockwave/default/english/win95nt/latest/Shockwave_Installer_Slim.exe", "group_name_matcher": "*Shockwave for Director*" }, "adobe-reader": { "mime_types": [ "application/pdf", "application/vnd.adobe.x-mars", "application/vnd.adobe.xdp+xml", "application/vnd.adobe.xfd+xml", "application/vnd.adobe.xfdf", "application/vnd.fdf" ], "versions": [ { "version": "10.1.13", "status": "requires_authorization", "reference": "https://helpx.adobe.com/security/products/reader/apsb14-28.html" }, { "version": "11", "status": "out_of_date" }, { "version": "11.0.10", "status": "requires_authorization", "reference": "https://helpx.adobe.com/security/products/reader/apsb14-28.html" } ], "lang": "en-US", "name": "Adobe Reader", "help_url": "https://support.google.com/chrome/?p=plugin_pdf", "url": "https://get.adobe.com/reader/", "displayurl": true, "group_name_matcher": "*Adobe Acrobat*" }, "apple-quicktime": { "mime_types": [ "application/sdp", "application/x-mpeg", "application/x-rtsp", "application/x-sdp", "audio/3ggp", "audio/3ggp2", "audio/aac", "audio/ac3", "audio/aiff", "audio/amr", "audio/basic", "audio/mid", "audio/midi", "audio/mp4", "audio/mpeg", "audio/vnd.qcelp", "audio/wav", "audio/x-aac", "audio/x-ac3", "audio/x-aiff", "audio/x-caf", "audio/x-gsm", "audio/x-m4a", "audio/x-m4b", "audio/x-m4p", "audio/x-midi", "audio/x-mpeg", "audio/x-wav", "image/jp2", "image/jpeg2000", "image/jpeg2000-image", "image/pict", "image/png", "image/x-jpeg2000-image", "image/x-macpaint", "image/x-pict", "image/x-png", "image/x-quicktime", "image/x-sgi", "image/x-targa", "video/3ggp", "video/3ggp2", "video/flc", "video/mp4", "video/mpeg", "video/quicktime", "video/sd-video", "video/x-m4v", "video/x-mpeg" ], "versions": [ { "version": "7.7.6", "status": "requires_authorization", "reference": "http://support.apple.com/kb/HT203092" } ], "lang": "en-US", "name": "QuickTime Player", "help_url": "https://support.google.com/chrome/?p=plugin_quicktime", "url": "http://appldnld.apple.com/QuickTime/041-3089.20111026.Sxpr4/QuickTimeInstaller.exe", "group_name_matcher": "*QuickTime Plug-in*" }, "windows-media-player": { "mime_types": [ ], "lang": "en-US", "name": "Windows Media Player", "help_url": "https://support.google.com/chrome/?p=plugin_wmp", "url": "http://www.interoperabilitybridges.com/wmp-extension-for-chrome", "displayurl": true, "group_name_matcher": "*Windows Media Player*" }, "divx-player": { "mime_types": [ "video/divx", "video/x-matroska" ], "versions": [ { "version": "1.4.3.4", "status": "requires_authorization" } ], "lang": "en-US", "name": "DivX Web Player", "help_url": "https://support.google.com/chrome/?p=plugin_divx", "url": "http://download.divx.com/player/divxdotcom/DivXWebPlayerInstaller.exe", "group_name_matcher": "*DivX Web Player*" }, "silverlight": { "mime_types": [ "application/x-silverlight", "application/x-silverlight-2" ], "versions": [ { "version": "5.1.40416.0", "status": "requires_authorization", "reference": "https://support.microsoft.com/kb/3056819" } ], "lang": "en-US", "name": "Silverlight", "url": "http://go.microsoft.com/fwlink/?LinkID=149156", "group_name_matcher": "*Silverlight*" }, "microsoft-office": { "mime_types": [ ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "Microsoft Office has no version information." } ], "name": "Microsoft Office", "group_name_matcher": "*Microsoft Office*" }, "nvidia-3d": { "mime_types": [ ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "NVidia 3D has no version information." } ], "name": "NVIDIA 3D", "group_name_matcher": "*NVIDIA 3D*" }, "google-chrome-pdf": { "mime_types": [ ], "versions": [ { "version": "0", "status": "fully_trusted", "comment": "Google Chrome PDF Viewer has no version information." } ], "name": "Chrome PDF Viewer", "group_name_matcher": "*Chrome PDF Viewer*" }, "chromium-pdf": { "mime_types": [ ], "versions": [ { "version": "0", "status": "fully_trusted", "comment": "Chromium PDF Viewer has no version information." } ], "name": "Chromium PDF Viewer", "group_name_matcher": "*Chromium PDF Viewer*" }, "google-chrome-pdf-plugin": { "mime_types": [ ], "versions": [ { "version": "0", "status": "fully_trusted", "comment": "Google Chrome PDF Plugin has no version information." } ], "name": "Chrome PDF Plugin", "group_name_matcher": "*Chrome PDF Plugin*" }, "chromium-pdf-plugin": { "mime_types": [ ], "versions": [ { "version": "0", "status": "fully_trusted", "comment": "Chromium PDF Plugin has no version information." } ], "name": "Chromium PDF Plugin", "group_name_matcher": "*Chromium PDF Plugin*" }, "google-update": { "mime-types": [ ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "Google Update plugin is versioned but kept automatically up to date" } ], "name": "Google Update", "group_name_matcher": "Google Update" }, "facebook-video-calling": { "mime_types": [ "application/skypesdk-plugin" ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "We do not track version information for the Facebook Video Calling Plugin." } ], "lang": "en-US", "name": "Facebook Video Calling", "url": "https://www.facebook.com/chat/video/videocalldownload.php", "group_name_matcher": "*Facebook Video*" }, "google-earth": { "mime_types": [ "application/geplugin" ], "versions": [ { "version": "0", "status": "requires_authorization", "comment": "We do not track version information for the Google Earth Plugin." } ], "lang": "en-US", "name": "Google Earth", "url": "http://www.google.com/earth/explore/products/plugin.html", "group_name_matcher": "*Google Earth*" } } { "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDQcByy+eN9jzazWF/DPn7NW47sW7lgmpk6eKc0BQM18q8hvEM3zNm2n7HkJv/R6fU+X5mtqkDuKvq5skF6qqUF4oEyaleWDFhd1xFwV7JV+/DU7bZ00w2+6gzqsabkerFpoP33ZRIw7OviJenP0c0uWqDWF8EGSyMhB3txqhOtiQIDAQAB", "name": "Bookmark Manager", "version": "0.1", "manifest_version": 2, "description": "Bookmark Manager", "icons": { // The favicon is loaded directly from resources.pak. }, "incognito" : "split", "permissions": [ "bookmarks", "bookmarkManagerPrivate", "metricsPrivate", "systemPrivate", "tabs", "chrome://favicon/", "chrome://resources/" ], "chrome_url_overrides": { "bookmarks": "main.html" }, "content_security_policy": "object-src 'none'; script-src chrome://resources 'self' blob: filesystem:" } { "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDqOhnwk4+HXVfGyaNsAQdU/js1Na56diW08oF1MhZiwzSnJsEaeuMN9od9q9N4ZdK3o1xXOSARrYdE+syV7Dl31nf6qz3A6K+D5NHe6sSB9yvYlIiN37jdWdrfxxE0pRYEVYZNTe3bzq3NkcYJlOdt1UPcpJB+isXpAGUKUvt7EQIDAQAB", "name": "Cloud Print", "version": "0.1", "description": "Cloud Print", "icons": { }, "app": { "launch": { "web_url": "https://www.google.com/cloudprint" }, "urls": [ "https://www.google.com/cloudprint/enable_chrome_connector" ] }, "permissions": [ "cloudPrintPrivate" ], "display_in_launcher": false } { "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtl3tO0osjuzRsf6xtD2SKxPlTfuoy7AWoObysitBPvH5fE1NaAA1/2JkPWkVDhdLBWLaIBPYeXbzlHp3y4Vv/4XG+aN5qFE3z+1RU/NqkzVYHtIpVScf3DjTYtKVL66mzVGijSoAIwbFCC3LpGdaoe6Q1rSRDp76wR6jjFzsYwQIDAQAB", "name": "Web Store", "version": "0.2", "description": "Chrome Web Store", "icons": { "16": "webstore_icon_16.png", "128": "webstore_icon_128.png" }, "app": { "launch": { "web_url": "https://chrome.google.com/webstore" }, "urls": [ "https://chrome.google.com/webstore" ] }, "permissions": [ "webstorePrivate", "management", "system.cpu", "system.display", "system.memory", "system.network", "system.storage" ] } { // chrome-extension://gfdkimpbcpahaombhbimeihdjnejgicl/ "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDMZElzFX2J1g1nRQ/8S3rg/1CjFyDltWOxQg+9M8aVgNVxbutEWFQz+oQzIP9BB67mJifULgiv12ToFKsae4NpEUR8sPZjiKDIHumc6pUdixOm8SJ5Rs16SMR6+VYxFUjlVW+5CA3IILptmNBxgpfyqoK0qRpBDIhGk1KDEZ4zqQIDAQAB", "name": "Feedback", "version": "1.0", "manifest_version": 2, "incognito" : "split", "description": "User feedback extension", "icons": { "32": "images/icon32.png", "64": "images/icon64.png" }, "permissions": [ "feedbackPrivate", "chrome://resources/" ], "app": { "background": { "scripts": ["js/event_handler.js"] }, "content_security_policy": "default-src 'none'; script-src 'self' blob: filesystem: chrome://resources; style-src 'unsafe-inline' blob: chrome: file: filesystem: data: *; img-src * blob: chrome: file: filesystem: data:; media-src 'self' blob: filesystem:" }, "display_in_launcher": false, "display_in_new_tab_page": false } { "background": { "scripts": [ "tts_extension.js" ], "persistent": false }, "description": "Component extension providing speech via the Google network text-to-speech service.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8GSbNUMGygqQTNDMFGIjZNcwXsHLzkNkHjWbuY37PbNdSDZ4VqlVjzbWqODSe+MjELdv5Keb51IdytnoGYXBMyqKmWpUrg+RnKvQ5ibWr4MW9pyIceOIdp9GrzC1WZGgTmZismYR3AjaIpufZ7xDdQQv+XrghPWCkdVqLN+qZDA1HU+DURznkMICiDDSH2sU0egm9UbWfS218bZqzKeQDiC3OnTPlaxcbJtKUuupIm5knjze3Wo9Ae9poTDMzKgchg0VlFCv3uqox+wlD8sjXBoyBCCK9HpImdVAF1a7jpdgiUHpPeV/26oYzM9/grltwNR3bzECQgSpyXp0eyoegwIDAQAB", "manifest_version": 2, "name": "Google Network Speech", "permissions": [ "systemPrivate", "ttsEngine", "https://www.google.com/" ], "tts_engine": { "voices": [ { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "de-DE", "voice_name": "Google Deutsch", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "en-US", "voice_name": "Google US English", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "en-GB", "voice_name": "Google UK English Female", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "male", "lang": "en-GB", "voice_name": "Google UK English Male", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "es-ES", "voice_name": "Google español", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "es-US", "voice_name": "Google español de Estados Unidos", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "fr-FR", "voice_name": "Google français", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "hi-IN", "voice_name": "Google हिन्दी", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "id-ID", "voice_name": "Google Bahasa Indonesia", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "it-IT", "voice_name": "Google italiano", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "ja-JP", "voice_name": "Google 日本語", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "ko-KR", "voice_name": "Google 한국의", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "nl-NL", "voice_name": "Google Nederlands", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "pl-PL", "voice_name": "Google polski", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "pt-BR", "voice_name": "Google português do Brasil", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "ru-RU", "voice_name": "Google русский", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "zh-CN", "voice_name": "Google 普通话(中国大陆)", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "zh-HK", "voice_name": "Google 粤語(香港)", "remote": true }, { "event_types": [ "start", "end", "error" ], "gender": "female", "lang": "zh-TW", "voice_name": "Google 國語(臺灣)", "remote": true } ] }, "version": "1.0" } { "name": "CryptoTokenExtension", "description": "CryptoToken Component Extension", "version": "0.9.71", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7zRobvA+AVlvNqkHSSVhh1sEWsHSqz4oR/XptkDe/Cz3+gW9ZGumZ20NCHjaac8j1iiesdigp8B1LJsd/2WWv2Dbnto4f8GrQ5MVphKyQ9WJHwejEHN2K4vzrTcwaXqv5BSTXwxlxS/mXCmXskTfryKTLuYrcHEWK8fCHb+0gvr8b/kvsi75A1aMmb6nUnFJvETmCkOCPNX5CHTdy634Ts/x0fLhRuPlahk63rdf7agxQv5viVjQFk+tbgv6aa9kdSd11Js/RZ9yZjrFgHOBWgP4jTBqud4+HUglrzu8qynFipyNRLCZsaxhm+NItTyNgesxLdxZcwOz56KD1Q4IQIDAQAB", "manifest_version": 2, "permissions": [ "hid", "u2fDevices", "usb", "cryptotokenPrivate", "externally_connectable.all_urls", "tabs", "https://*/*", "http://*/*", { "usbDevices": [ { "vendorId": 4176, "productId": 529 } ] } ], "externally_connectable": { "matches": [ "" ], "ids": [ "fjajfjhkeibgmiggdfehjplbhmfkialk" ], "accepts_tls_channel_id": true }, "background": { "persistent": false, "scripts": [ "util.js", "b64.js", "sha256.js", "timer.js", "countdown.js", "countdowntimer.js", "devicestatuscodes.js", "approvedorigins.js", "errorcodes.js", "webrequest.js", "messagetypes.js", "factoryregistry.js", "closeable.js", "requesthelper.js", "asn1.js", "enroller.js", "requestqueue.js", "signer.js", "origincheck.js", "textfetcher.js", "appid.js", "watchdog.js", "logging.js", "webrequestsender.js", "window-timer.js", "cryptotokenorigincheck.js", "cryptotokenapprovedorigins.js", "gnubbydevice.js", "hidgnubbydevice.js", "usbgnubbydevice.js", "gnubbies.js", "gnubby.js", "gnubby-u2f.js", "gnubbyfactory.js", "singlesigner.js", "multiplesigner.js", "generichelper.js", "inherits.js", "individualattest.js", "devicefactoryregistry.js", "usbhelper.js", "usbenrollhandler.js", "usbsignhandler.js", "usbgnubbyfactory.js", "googlecorpindividualattest.js", "cryptotokenbackground.js" ] }, "incognito": "split" } { // chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai "manifest_version": 2, "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDN6hM0rsDYGbzQPQfOygqlRtQgKUXMfnSjhIBL7LnReAVBEd7ZmKtyN2qmSasMl4HZpMhVe2rPWVVwBDl6iyNE/Kok6E6v6V3vCLGsOpQAuuNVye/3QxzIldzG/jQAdWZiyXReRVapOhZtLjGfywCvlWq7Sl/e3sbc0vWybSDI2QIDAQAB", "name": "", "version": "1", "description": "", "offline_enabled": true, "incognito": "split", "permissions": [ "", "metricsPrivate", "resourcesPrivate" ], "mime_types": [ "application/pdf" ], "content_security_policy": "script-src 'self' blob: filesystem: chrome://resources; object-src * blob: externalfile: file: filesystem: data:; plugin-types application/x-google-chrome-pdf", "mime_types_handler": "index.html", "web_accessible_resources": [ "*.js", "*.html", "*.css", "*.png" ] }
/* 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. */ html { /* Constants. */ --tile-height: 128px; --tile-margin: 16px; --tile-width: 154px; --title-height: 32px; /* May be overridden by themes (on the body element). */ --tile-title-color: #323232; } body { background: none transparent; color: var(--tile-title-color); margin: 0; overflow: hidden; padding: 0; user-select: none; } a { display: block; } a, a:active, a:hover, a:visited { color: inherit; text-decoration: none; } #most-visited { margin: 0; text-align: -webkit-center; user-select: none; } #mv-tiles, .mv-tiles-old { font-size: 0; /* Two rows of tiles of 128px each, and 16px of spacing between the rows. * If you change this, also change the corresponding values in * local_ntp.css. */ height: calc(2*var(--tile-height) + var(--tile-margin)); line-height: calc(var(--tile-height) + var(--tile-margin)); margin: 4px 0 8px 0; opacity: 0; position: absolute; /* This align correctly for both LTR and RTL */ text-align: -webkit-auto; transition: opacity 1s; user-select: none; } .mv-tile, .mv-empty-tile { border-radius: 2px; box-sizing: border-box; display: none; font-family: arial, sans-serif; font-size: 12px; height: var(--tile-height); line-height: 100%; margin: 0 calc(var(--tile-margin) / 2); opacity: 1; position: relative; vertical-align: top; white-space: nowrap; width: var(--tile-width); } /* Min height for showing 1 row: 4px + 128px + 8px Min height for showing 2 rows: 4px + 128px + 16px + 128px + 8px In both cases, give it half a px of tolerance, because otherwise rounding errors at some zoom levels break this and a row sometimes doesn't show up. */ /* Minimal layout: 1 row, 2 columns; only first 2 tiles are visible. */ @media (min-height: 139.5px) { .mv-tile:nth-child(-n+2), .mv-empty-tile:nth-child(-n+2) { display: inline-block; } } /* width >= (3 cols * (16px + 154px)) * 1 row, 3 columns; first 3 tiles are visible. */ @media (min-height: 139.5px) and (min-width: 510px) { .mv-tile:nth-child(-n+3), .mv-empty-tile:nth-child(-n+3) { display: inline-block; } } /* width >= (4 cols * (16px + 154px)) * 1 row, 4 columns; first 4 tiles are visible. */ @media (min-height: 139.5px) and (min-width: 680px) { .mv-tile:nth-child(-n+4), .mv-empty-tile:nth-child(-n+4) { display: inline-block; } } /* 2 rows, 2 columns; only first 4 tiles are visible. */ @media (min-height: 283.5px) { .mv-tile:nth-child(-n+4), .mv-empty-tile:nth-child(-n+4) { display: inline-block; } } /* width >= (3 cols * (16px + 154px)) * 2 rows, 3 columns; first 6 tiles are visible. */ @media (min-height: 283.5px) and (min-width: 510px) { .mv-tile:nth-child(-n+6), .mv-empty-tile:nth-child(-n+6) { display: inline-block; } } /* width >= (4 cols * (16px + 154px)) * 2 rows, 4 columns; first 8 tiles are visible. */ @media (min-height: 283.5px) and (min-width: 680px) { .mv-tile:nth-child(-n+8), .mv-empty-tile:nth-child(-n+8) { display: inline-block; } } .mv-tile { background: rgb(250,250,250); } .mv-empty-tile { background: rgb(245,245,245); } body.dark-theme .mv-tile, body.dark-theme .mv-empty-tile { background: rgb(51,51,51); } .mv-tile { box-shadow: 0 2px 2px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08); cursor: pointer; transition-duration: 200ms; transition-property: transform, box-shadow, margin, opacity, width; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } .mv-tile:hover:not(:active), .mv-tile:focus-within:not(:active) { box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.08); } .mv-tile:focus, .mv-tile:focus-within { filter: brightness(92%); } .mv-tile:active { box-shadow: 0 3px 8px 0 rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.12); filter: brightness(88%); } .mv-tile.blacklisted { margin: 0; transform: scale(0, 0); width: 0; } .mv-title { height: 15px; left: 31px; line-height: 14px; overflow: hidden; padding: 0; position: absolute; text-overflow: ellipsis; top: 9px; width: 120px; } .mv-title.multiline { white-space: pre-wrap; word-wrap: break-word; } html:not([dir=rtl]) .mv-title[style*='direction: rtl'] { -webkit-mask-image: linear-gradient(to left, black, black, 100px, transparent); left: auto; right: 8px; text-align: right; } html[dir=rtl] .mv-title { left: 8px; text-align: left; } html[dir=rtl] .mv-title[style*='direction: rtl'] { -webkit-mask-image: linear-gradient(to left, black, black, 100px, transparent); right: 31px; text-align: right; } .mv-thumb { border-radius: 0 0 2px 2px; cursor: pointer; display: block; height: calc(var(--tile-height) - var(--title-height)); overflow: hidden; position: absolute; top: var(--title-height); width: var(--tile-width); } .mv-thumb img { height: auto; min-height: 100%; width: 100%; } .mv-thumb.failed-img { background-color: rgb(245,245,245); } body.dark-theme .mv-thumb.failed-img { background-color: #555; } /* We use ::after without content to provide an aditional element on top of the * thumbnail. */ .mv-thumb.failed-img::after { border: 8px solid rgb(215,215,215); border-radius: 50%; content: ''; display: block; height: 0; margin: 39px 66px; width: 0; } body.dark-theme .mv-thumb.failed-img::after { border-color: #333; } .mv-x { background: linear-gradient(to left, rgb(250,250,250) 60%, transparent); border: none; cursor: pointer; height: var(--title-height); opacity: 0; padding: 0; position: absolute; right: 0; transition: opacity 150ms; width: 40px; } body.dark-theme .mv-x { background: linear-gradient(to left, rgb(51,51,51) 60%, transparent); } /* We use ::after without content to provide the masked X element. The "bottom" * div is actually just the gradient. */ .mv-x::after { --mask-offset: calc((var(--title-height) - var(--mask-width)) / 2); --mask-width: 10px; -webkit-mask-image: -webkit-image-set( url(chrome-search://local-ntp/images/close_3_mask.png) 1x, url(chrome-search://local-ntp/images/close_3_mask.png@2x) 2x); -webkit-mask-position: var(--mask-offset) var(--mask-offset); -webkit-mask-repeat: no-repeat; -webkit-mask-size: var(--mask-width); background-color: rgba(90,90,90,0.7); content: ''; display: block; height: var(--title-height); position: absolute; right: 0; top: 0; width: var(--title-height); } body.dark-theme .mv-x.mv-x::after { background-color: rgba(255,255,255,0.7); } html[dir=rtl] .mv-x { background: linear-gradient(to right, rgb(250,250,250) 60%, transparent); left: -1px; right: auto; } body.dark-theme body.dark-theme .mv-x { background: linear-gradient(to right, rgb(51,51,51) 60%, transparent); } html[dir=rtl] .mv-x::after { left: -1px; right: auto; } .mv-x:hover::after { background-color: rgb(90,90,90); } body.dark-theme .mv-x:hover::after { background-color: #fff; } .mv-x:active::after { background-color: rgb(66,133,244); } body.dark-theme .mv-x:active::after { background-color: rgba(255,255,255,0.5); } .mv-tile:hover .mv-x, .mv-tile:focus .mv-x { opacity: 1; transition-delay: 500ms; } .mv-x:hover, .mv-x:focus { opacity: 1; transition: none; } .mv-favicon { background-size: 16px; height: 16px; left: 7px; margin: 0; pointer-events: none; position: absolute; top: 8px; width: 16px; } html[dir=rtl] .mv-favicon { left: auto; right: 7px; } .mv-favicon.failed-favicon { background-image: -webkit-image-set( url(chrome-search://local-ntp/images/ntp_default_favicon.png) 1x, url(chrome-search://local-ntp/images/ntp_default_favicon.png@2x) 2x); background-repeat: no-repeat; background-size: 16px 16px; } .mv-favicon img { height: 100%; width: 100%; } .mv-favicon.failed-favicon img { 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. */ // Single iframe for NTP tiles. (function() { 'use strict'; /** * The different types of events that are logged from the NTP. This enum is * used to transfer information from the NTP JavaScript to the renderer and is * not used as a UMA enum histogram's logged value. * Note: Keep in sync with common/ntp_logging_events.h * @enum {number} * @const */ var LOG_TYPE = { // All NTP tiles have finished loading (successfully or failing). NTP_ALL_TILES_LOADED: 11, // The data for all NTP tiles (title, URL, etc, but not the thumbnail image) // has been received. In contrast to NTP_ALL_TILES_LOADED, this is recorded // before the actual DOM elements have loaded (in particular the thumbnail // images). NTP_ALL_TILES_RECEIVED: 12, }; /** * The different sources where an NTP tile's title can originate from. * Note: Keep in sync with components/ntp_tiles/tile_title_source.h * @enum {number} * @const */ var TileTitleSource = { UNKNOWN: 0, MANIFEST: 1, META_TAG: 2, TITLE: 3, INFERRED: 4 }; /** * The different sources that an NTP tile can have. * Note: Keep in sync with components/ntp_tiles/tile_source.h * @enum {number} * @const */ var TileSource = { TOP_SITES: 0, SUGGESTIONS_SERVICE: 1, POPULAR: 3, WHITELIST: 4, }; /** * The different (visual) types that an NTP tile can have. * Note: Keep in sync with components/ntp_tiles/tile_visual_type.h * @enum {number} * @const */ var TileVisualType = { NONE: 0, ICON_REAL: 1, ICON_COLOR: 2, ICON_DEFAULT: 3, THUMBNAIL: 7, THUMBNAIL_FAILED: 8, }; /** * Total number of tiles to show at any time. If the host page doesn't send * enough tiles, we fill them blank. * @const {number} */ var NUMBER_OF_TILES = 8; /** * Number of lines to display in titles. * @type {number} */ var NUM_TITLE_LINES = 1; /** * The origin of this request, i.e. 'https://www.google.TLD' for the remote NTP, * or 'chrome-search://local-ntp' for the local NTP. * @const {string} */ var DOMAIN_ORIGIN = '{{ORIGIN}}'; /** * Counter for DOM elements that we are waiting to finish loading. Starts out * at 1 because initially we're waiting for the "show" message from the parent. * @type {number} */ var loadedCounter = 1; /** * DOM element containing the tiles we are going to present next. * Works as a double-buffer that is shown when we receive a "show" postMessage. * @type {Element} */ var tiles = null; /** * List of parameters passed by query args. * @type {Object} */ var queryArgs = {}; /** * Log an event on the NTP. * @param {number} eventType Event from LOG_TYPE. */ var logEvent = function(eventType) { chrome.embeddedSearch.newTabPage.logEvent(eventType); }; /** * Log impression of an NTP tile. * @param {number} tileIndex Position of the tile, >= 0 and < NUMBER_OF_TILES. * @param {number} tileTitleSource The title's source from TileTitleSource. * @param {number} tileSource The source from TileSource. * @param {number} tileType The type from TileVisualType. * @param {Date} dataGenerationTime Timestamp representing when the tile was * produced by a ranking algorithm. */ function logMostVisitedImpression( tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime) { chrome.embeddedSearch.newTabPage.logMostVisitedImpression( tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime); } /** * Log click on an NTP tile. * @param {number} tileIndex Position of the tile, >= 0 and < NUMBER_OF_TILES. * @param {number} tileTitleSource The title's source from TileTitleSource. * @param {number} tileSource The source from TileSource. * @param {number} tileType The type from TileVisualType. * @param {Date} dataGenerationTime Timestamp representing when the tile was * produced by a ranking algorithm. */ function logMostVisitedNavigation( tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime) { chrome.embeddedSearch.newTabPage.logMostVisitedNavigation( tileIndex, tileTitleSource, tileSource, tileType, dataGenerationTime); } /** * Down counts the DOM elements that we are waiting for the page to load. * When we get to 0, we send a message to the parent window. * This is usually used as an EventListener of onload/onerror. */ var countLoad = function() { loadedCounter -= 1; if (loadedCounter <= 0) { swapInNewTiles(); logEvent(LOG_TYPE.NTP_ALL_TILES_LOADED); window.parent.postMessage({cmd: 'loaded'}, DOMAIN_ORIGIN); // Reset to 1, so that any further 'show' message will cause us to swap in // fresh tiles. loadedCounter = 1; } }; /** * Handles postMessages coming from the host page to the iframe. * Mostly, it dispatches every command to handleCommand. */ var handlePostMessage = function(event) { if (event.data instanceof Array) { for (var i = 0; i < event.data.length; ++i) { handleCommand(event.data[i]); } } else { handleCommand(event.data); } }; /** * Handles a single command coming from the host page to the iframe. * We try to keep the logic here to a minimum and just dispatch to the relevant * functions. */ var handleCommand = function(data) { var cmd = data.cmd; if (cmd == 'tile') { addTile(data); } else if (cmd == 'show') { // TODO(treib): If this happens before we have finished loading the previous // tiles, we probably get into a bad state. showTiles(data); } else if (cmd == 'updateTheme') { updateTheme(data); } else { console.error('Unknown command: ' + JSON.stringify(data)); } }; /** * Handler for the 'show' message from the host page. * @param {object} info Data received in the message. */ var showTiles = function(info) { logEvent(LOG_TYPE.NTP_ALL_TILES_RECEIVED); countLoad(); }; /** * Handler for the 'updateTheme' message from the host page. * @param {object} info Data received in the message. */ var updateTheme = function(info) { document.body.style.setProperty('--tile-title-color', info.tileTitleColor); document.body.classList.toggle('dark-theme', info.isThemeDark); }; /** * Removes all old instances of #mv-tiles that are pending for deletion. */ var removeAllOldTiles = function() { var parent = document.querySelector('#most-visited'); var oldList = parent.querySelectorAll('.mv-tiles-old'); for (var i = 0; i < oldList.length; ++i) { parent.removeChild(oldList[i]); } }; /** * Called when all tiles have finished loading (successfully or not), including * their thumbnail images, and we are ready to show the new tiles and drop the * old ones. */ var swapInNewTiles = function() { // Store the tiles on the current closure. var cur = tiles; // Create empty tiles until we have NUMBER_OF_TILES. while (cur.childNodes.length < NUMBER_OF_TILES) { addTile({}); } var parent = document.querySelector('#most-visited'); // Only fade in the new tiles if there were tiles before. var fadeIn = false; var old = parent.querySelector('#mv-tiles'); if (old) { fadeIn = true; // Mark old tile DIV for removal after the transition animation is done. old.removeAttribute('id'); old.classList.add('mv-tiles-old'); old.style.opacity = 0.0; cur.addEventListener('transitionend', function(ev) { if (ev.target === cur) { removeAllOldTiles(); } }); } // Add new tileset. cur.id = 'mv-tiles'; parent.appendChild(cur); // getComputedStyle causes the initial style (opacity 0) to be applied, so // that when we then set it to 1, that triggers the CSS transition. if (fadeIn) { window.getComputedStyle(cur).opacity; } cur.style.opacity = 1.0; // Make sure the tiles variable contain the next tileset we'll use if the host // page sends us an updated set of tiles. tiles = document.createElement('div'); }; /** * Handler for the 'show' message from the host page, called when it wants to * add a suggestion tile. * It's also used to fill up our tiles to NUMBER_OF_TILES if necessary. * @param {object} args Data for the tile to be rendered. */ var addTile = function(args) { if (isFinite(args.rid)) { // An actual suggestion. Grab the data from the embeddedSearch API. var data = chrome.embeddedSearch.newTabPage.getMostVisitedItemData(args.rid); if (!data) return; data.tid = data.rid; if (!data.faviconUrl) { data.faviconUrl = 'chrome-search://favicon/size/16@' + window.devicePixelRatio + 'x/' + data.renderViewId + '/' + data.tid; } tiles.appendChild(renderTile(data)); } else { // An empty tile tiles.appendChild(renderTile(null)); } }; /** * Called when the user decided to add a tile to the blacklist. * It sets off the animation for the blacklist and sends the blacklisted id * to the host page. * @param {Element} tile DOM node of the tile we want to remove. */ var blacklistTile = function(tile) { tile.classList.add('blacklisted'); tile.addEventListener('transitionend', function(ev) { if (ev.propertyName != 'width') return; window.parent.postMessage( {cmd: 'tileBlacklisted', tid: Number(tile.getAttribute('data-tid'))}, DOMAIN_ORIGIN); }); }; /** * Returns whether the given URL has a known, safe scheme. * @param {string} url URL to check. */ var isSchemeAllowed = function(url) { return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://') || url.startsWith('chrome-extension://'); }; /** * Renders a MostVisited tile to the DOM. * @param {object} data Object containing rid, url, title, favicon, thumbnail. * data is null if you want to construct an empty tile. */ var renderTile = function(data) { var tile = document.createElement('a'); if (data == null) { tile.className = 'mv-empty-tile'; return tile; } // The tile will be appended to tiles. var position = tiles.children.length; // This is set in the load/error event for the thumbnail image. var tileType = TileVisualType.NONE; tile.className = 'mv-tile'; tile.setAttribute('data-tid', data.tid); if (isSchemeAllowed(data.url)) { tile.href = data.url; } tile.setAttribute('aria-label', data.title); tile.title = data.title; tile.addEventListener('click', function(ev) { logMostVisitedNavigation( position, data.tileTitleSource, data.tileSource, tileType, data.dataGenerationTime); }); tile.addEventListener('keydown', function(event) { if (event.keyCode == 46 /* DELETE */ || event.keyCode == 8 /* BACKSPACE */) { event.preventDefault(); event.stopPropagation(); blacklistTile(this); } else if ( event.keyCode == 13 /* ENTER */ || event.keyCode == 32 /* SPACE */) { event.preventDefault(); this.click(); } else if (event.keyCode >= 37 && event.keyCode <= 40 /* ARROWS */) { // specify the direction of movement var inArrowDirection = function(origin, target) { return (event.keyCode == 37 /* LEFT */ && origin.offsetTop == target.offsetTop && origin.offsetLeft > target.offsetLeft) || (event.keyCode == 38 /* UP */ && origin.offsetTop > target.offsetTop && origin.offsetLeft == target.offsetLeft) || (event.keyCode == 39 /* RIGHT */ && origin.offsetTop == target.offsetTop && origin.offsetLeft < target.offsetLeft) || (event.keyCode == 40 /* DOWN */ && origin.offsetTop < target.offsetTop && origin.offsetLeft == target.offsetLeft); }; var nonEmptyTiles = document.querySelectorAll('#mv-tiles .mv-tile'); var nextTile = null; // Find the closest tile in the appropriate direction. for (var i = 0; i < nonEmptyTiles.length; i++) { if (inArrowDirection(this, nonEmptyTiles[i]) && (!nextTile || inArrowDirection(nonEmptyTiles[i], nextTile))) { nextTile = nonEmptyTiles[i]; } } if (nextTile) { nextTile.focus(); } } }); var favicon = document.createElement('div'); favicon.className = 'mv-favicon'; var fi = document.createElement('img'); fi.src = data.faviconUrl; // Set title and alt to empty so screen readers won't say the image name. fi.title = ''; fi.alt = ''; loadedCounter += 1; fi.addEventListener('load', countLoad); fi.addEventListener('error', countLoad); fi.addEventListener('error', function(ev) { favicon.classList.add('failed-favicon'); }); favicon.appendChild(fi); tile.appendChild(favicon); var title = document.createElement('div'); title.className = 'mv-title'; title.innerText = data.title; title.style.direction = data.direction || 'ltr'; if (NUM_TITLE_LINES > 1) { title.classList.add('multiline'); } tile.appendChild(title); var thumb = document.createElement('div'); thumb.className = 'mv-thumb'; var img = document.createElement('img'); img.title = data.title; img.src = data.thumbnailUrl; loadedCounter += 1; img.addEventListener('load', function(ev) { // Store the type for a potential later navigation. tileType = TileVisualType.THUMBNAIL; logMostVisitedImpression( position, data.tileTitleSource, data.tileSource, tileType, data.dataGenerationTime); // Note: It's important to call countLoad last, because that might emit the // NTP_ALL_TILES_LOADED event, which must happen after the impression log. countLoad(); }); img.addEventListener('error', function(ev) { thumb.classList.add('failed-img'); thumb.removeChild(img); // Store the type for a potential later navigation. tileType = TileVisualType.THUMBNAIL_FAILED; logMostVisitedImpression( position, data.tileTitleSource, data.tileSource, tileType, data.dataGenerationTime); // Note: It's important to call countLoad last, because that might emit the // NTP_ALL_TILES_LOADED event, which must happen after the impression log. countLoad(); }); thumb.appendChild(img); tile.appendChild(thumb); var mvx = document.createElement('button'); mvx.className = 'mv-x'; mvx.title = queryArgs['removeTooltip'] || ''; mvx.addEventListener('click', function(ev) { removeAllOldTiles(); blacklistTile(tile); ev.preventDefault(); ev.stopPropagation(); }); // Don't allow the event to bubble out to the containing tile, as that would // trigger navigation to the tile URL. mvx.addEventListener('keydown', function(event) { event.stopPropagation(); }); tile.appendChild(mvx); return tile; }; /** * Does some initialization and parses the query arguments passed to the iframe. */ var init = function() { // Create a new DOM element to hold the tiles. The tiles will be added // one-by-one via addTile, and the whole thing will be inserted into the page // in swapInNewTiles, after the parent has sent us the 'show' message, and all // thumbnails and favicons have loaded. tiles = document.createElement('div'); // Parse query arguments. var query = window.location.search.substring(1).split('&'); queryArgs = {}; for (var i = 0; i < query.length; ++i) { var val = query[i].split('='); if (val[0] == '') continue; queryArgs[decodeURIComponent(val[0])] = decodeURIComponent(val[1]); } if ('ntl' in queryArgs) { var ntl = parseInt(queryArgs['ntl'], 10); if (isFinite(ntl)) NUM_TITLE_LINES = ntl; } // Enable RTL. if (queryArgs['rtl'] == '1') { var html = document.querySelector('html'); html.dir = 'rtl'; } window.addEventListener('message', handlePostMessage); }; window.addEventListener('DOMContentLoaded', init); })();
/* 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. */ html { /* This will be overridden based on the viewport width, see below. */ --column-count: 2; --content-width: calc( (var(--column-count) * (var(--tile-width) + var(--tile-margin))) /* We add an extra pixel because rounding errors on different zooms can * make the width shorter than it should be. */ + 1px); --logo-height: 200px; /* Normal height of a doodle. */ --logo-margin-top: 56px; /* Expected OGB height, so logo doesn't overlap. */ --logo-margin-bottom: 29px; /* Between logo and fakebox. */ --tile-height: 128px; --tile-margin: 16px; --tile-width: 154px; /* Two rows of tiles, margin between the rows, and 4px/8px of margin on top * and bottom respectively. If you change this, also change the corresponding * values in most_visited_single.css. */ --mv-tiles-height: calc( 4px + var(--tile-height) + var(--tile-margin) + var(--tile-height) + 8px); /* Base height 16px, plus 8px each of padding on top and bottom. */ --mv-notice-height: calc(8px + 16px + 8px); --mv-notice-time: 10s; /* These can be overridden by themes. */ --text-color: #000; --text-color-light: #fff; --text-color-link: rgb(17, 85, 204); height: 100%; } /* width >= (3 cols * (16px + 154px) - 16px + 200px) */ @media (min-width: 694px) { html { --column-count: 3; } } /* width >= (4 cols * (16px + 154px) - 16px + 200px) */ @media (min-width: 864px) { html { --column-count: 4; } } /* TODO: Need to discuss with NTP folks before we remove font-family from the * body tag. */ body { background-attachment: fixed !important; cursor: default; display: none; font-family: arial, sans-serif; font-size: small; height: 100%; margin: 0; overflow-x: hidden; } body.inited { display: block; } /* Button defaults vary by platform. Reset CSS so that the NTP can use buttons * as a kind of clickable div. */ button { background: transparent; border: 0; margin: 0; padding: 0; } #ntp-contents { display: flex; flex-direction: column; height: 100%; } #logo, #fakebox-container { flex-shrink: 0; } .non-google-page #ntp-contents { justify-content: center; } body.hide-fakebox-logo #logo, body.hide-fakebox-logo #fakebox { opacity: 0; } #logo { height: calc(var(--logo-height) + var(--logo-margin-bottom)); margin-top: var(--logo-margin-top); min-height: fit-content; position: relative; } .non-google-page #logo { display: none; } #logo-default, #logo-doodle { opacity: 0; visibility: hidden; } #logo-default.show-logo, #logo-doodle.show-logo { opacity: 1; visibility: visible; } #logo-doodle-button, #logo-doodle-iframe { display: none; } #logo-doodle-button.show-logo, #logo-doodle-iframe.show-logo { display: block; } #logo-default.fade, #logo-doodle.fade { transition: opacity 130ms, visibility 130ms; } #logo-default, #logo-non-white { background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNzIgOTIiIHdpZHRoPSIyNzIiIGhlaWdodD0iOTIiPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0xMTUuNzUgNDcuMThjMCAxMi43Ny05Ljk5IDIyLjE4LTIyLjI1IDIyLjE4cy0yMi4yNS05LjQxLTIyLjI1LTIyLjE4QzcxLjI1IDM0LjMyIDgxLjI0IDI1IDkzLjUgMjVzMjIuMjUgOS4zMiAyMi4yNSAyMi4xOHptLTkuNzQgMGMwLTcuOTgtNS43OS0xMy40NC0xMi41MS0xMy40NFM4MC45OSAzOS4yIDgwLjk5IDQ3LjE4YzAgNy45IDUuNzkgMTMuNDQgMTIuNTEgMTMuNDRzMTIuNTEtNS41NSAxMi41MS0xMy40NHoiLz48cGF0aCBmaWxsPSIjRkJCQzA1IiBkPSJNMTYzLjc1IDQ3LjE4YzAgMTIuNzctOS45OSAyMi4xOC0yMi4yNSAyMi4xOHMtMjIuMjUtOS40MS0yMi4yNS0yMi4xOGMwLTEyLjg1IDkuOTktMjIuMTggMjIuMjUtMjIuMThzMjIuMjUgOS4zMiAyMi4yNSAyMi4xOHptLTkuNzQgMGMwLTcuOTgtNS43OS0xMy40NC0xMi41MS0xMy40NHMtMTIuNTEgNS40Ni0xMi41MSAxMy40NGMwIDcuOSA1Ljc5IDEzLjQ0IDEyLjUxIDEzLjQ0czEyLjUxLTUuNTUgMTIuNTEtMTMuNDR6Ii8+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTTIwOS43NSAyNi4zNHYzOS44MmMwIDE2LjM4LTkuNjYgMjMuMDctMjEuMDggMjMuMDctMTAuNzUgMC0xNy4yMi03LjE5LTE5LjY2LTEzLjA3bDguNDgtMy41M2MxLjUxIDMuNjEgNS4yMSA3Ljg3IDExLjE3IDcuODcgNy4zMSAwIDExLjg0LTQuNTEgMTEuODQtMTN2LTMuMTloLS4zNGMtMi4xOCAyLjY5LTYuMzggNS4wNC0xMS42OCA1LjA0LTExLjA5IDAtMjEuMjUtOS42Ni0yMS4yNS0yMi4wOSAwLTEyLjUyIDEwLjE2LTIyLjI2IDIxLjI1LTIyLjI2IDUuMjkgMCA5LjQ5IDIuMzUgMTEuNjggNC45NmguMzR2LTMuNjFoOS4yNXptLTguNTYgMjAuOTJjMC03LjgxLTUuMjEtMTMuNTItMTEuODQtMTMuNTItNi43MiAwLTEyLjM1IDUuNzEtMTIuMzUgMTMuNTIgMCA3LjczIDUuNjMgMTMuMzYgMTIuMzUgMTMuMzYgNi42MyAwIDExLjg0LTUuNjMgMTEuODQtMTMuMzZ6Ii8+PHBhdGggZmlsbD0iIzM0QTg1MyIgZD0iTTIyNSAzdjY1aC05LjVWM2g5LjV6Ii8+PHBhdGggZmlsbD0iI0VBNDMzNSIgZD0iTTI2Mi4wMiA1NC40OGw3LjU2IDUuMDRjLTIuNDQgMy42MS04LjMyIDkuODMtMTguNDggOS44My0xMi42IDAtMjIuMDEtOS43NC0yMi4wMS0yMi4xOCAwLTEzLjE5IDkuNDktMjIuMTggMjAuOTItMjIuMTggMTEuNTEgMCAxNy4xNCA5LjE2IDE4Ljk4IDE0LjExbDEuMDEgMi41Mi0yOS42NSAxMi4yOGMyLjI3IDQuNDUgNS44IDYuNzIgMTAuNzUgNi43MiA0Ljk2IDAgOC40LTIuNDQgMTAuOTItNi4xNHptLTIzLjI3LTcuOThsMTkuODItOC4yM2MtMS4wOS0yLjc3LTQuMzctNC43LTguMjMtNC43LTQuOTUgMC0xMS44NCA0LjM3LTExLjU5IDEyLjkzeiIvPjxwYXRoIGZpbGw9IiM0Mjg1RjQiIGQ9Ik0zNS4yOSA0MS40MVYzMkg2N2MuMzEgMS42NC40NyAzLjU4LjQ3IDUuNjggMCA3LjA2LTEuOTMgMTUuNzktOC4xNSAyMi4wMS02LjA1IDYuMy0xMy43OCA5LjY2LTI0LjAyIDkuNjZDMTYuMzIgNjkuMzUuMzYgNTMuODkuMzYgMzQuOTEuMzYgMTUuOTMgMTYuMzIuNDcgMzUuMy40N2MxMC41IDAgMTcuOTggNC4xMiAyMy42IDkuNDlsLTYuNjQgNi42NGMtNC4wMy0zLjc4LTkuNDktNi43Mi0xNi45Ny02LjcyLTEzLjg2IDAtMjQuNyAxMS4xNy0yNC43IDI1LjAzIDAgMTMuODYgMTAuODQgMjUuMDMgMjQuNyAyNS4wMyA4Ljk5IDAgMTQuMTEtMy42MSAxNy4zOS02Ljg5IDIuNjYtMi42NiA0LjQxLTYuNDYgNS4xLTExLjY1bC0yMi40OS4wMXoiLz48L3N2Zz4=); background-repeat: no-repeat; bottom: var(--logo-margin-bottom); height: 92px; left: calc(50% - 272px/2); position: absolute; width: 272px; } body.alternate-logo #logo-default, body.alternate-logo #logo-non-white { -webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNzIgOTIiIHdpZHRoPSIyNzIiIGhlaWdodD0iOTIiPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0xMTUuNzUgNDcuMThjMCAxMi43Ny05Ljk5IDIyLjE4LTIyLjI1IDIyLjE4cy0yMi4yNS05LjQxLTIyLjI1LTIyLjE4QzcxLjI1IDM0LjMyIDgxLjI0IDI1IDkzLjUgMjVzMjIuMjUgOS4zMiAyMi4yNSAyMi4xOHptLTkuNzQgMGMwLTcuOTgtNS43OS0xMy40NC0xMi41MS0xMy40NFM4MC45OSAzOS4yIDgwLjk5IDQ3LjE4YzAgNy45IDUuNzkgMTMuNDQgMTIuNTEgMTMuNDRzMTIuNTEtNS41NSAxMi41MS0xMy40NHoiLz48cGF0aCBmaWxsPSIjRkJCQzA1IiBkPSJNMTYzLjc1IDQ3LjE4YzAgMTIuNzctOS45OSAyMi4xOC0yMi4yNSAyMi4xOHMtMjIuMjUtOS40MS0yMi4yNS0yMi4xOGMwLTEyLjg1IDkuOTktMjIuMTggMjIuMjUtMjIuMThzMjIuMjUgOS4zMiAyMi4yNSAyMi4xOHptLTkuNzQgMGMwLTcuOTgtNS43OS0xMy40NC0xMi41MS0xMy40NHMtMTIuNTEgNS40Ni0xMi41MSAxMy40NGMwIDcuOSA1Ljc5IDEzLjQ0IDEyLjUxIDEzLjQ0czEyLjUxLTUuNTUgMTIuNTEtMTMuNDR6Ii8+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTTIwOS43NSAyNi4zNHYzOS44MmMwIDE2LjM4LTkuNjYgMjMuMDctMjEuMDggMjMuMDctMTAuNzUgMC0xNy4yMi03LjE5LTE5LjY2LTEzLjA3bDguNDgtMy41M2MxLjUxIDMuNjEgNS4yMSA3Ljg3IDExLjE3IDcuODcgNy4zMSAwIDExLjg0LTQuNTEgMTEuODQtMTN2LTMuMTloLS4zNGMtMi4xOCAyLjY5LTYuMzggNS4wNC0xMS42OCA1LjA0LTExLjA5IDAtMjEuMjUtOS42Ni0yMS4yNS0yMi4wOSAwLTEyLjUyIDEwLjE2LTIyLjI2IDIxLjI1LTIyLjI2IDUuMjkgMCA5LjQ5IDIuMzUgMTEuNjggNC45NmguMzR2LTMuNjFoOS4yNXptLTguNTYgMjAuOTJjMC03LjgxLTUuMjEtMTMuNTItMTEuODQtMTMuNTItNi43MiAwLTEyLjM1IDUuNzEtMTIuMzUgMTMuNTIgMCA3LjczIDUuNjMgMTMuMzYgMTIuMzUgMTMuMzYgNi42MyAwIDExLjg0LTUuNjMgMTEuODQtMTMuMzZ6Ii8+PHBhdGggZmlsbD0iIzM0QTg1MyIgZD0iTTIyNSAzdjY1aC05LjVWM2g5LjV6Ii8+PHBhdGggZmlsbD0iI0VBNDMzNSIgZD0iTTI2Mi4wMiA1NC40OGw3LjU2IDUuMDRjLTIuNDQgMy42MS04LjMyIDkuODMtMTguNDggOS44My0xMi42IDAtMjIuMDEtOS43NC0yMi4wMS0yMi4xOCAwLTEzLjE5IDkuNDktMjIuMTggMjAuOTItMjIuMTggMTEuNTEgMCAxNy4xNCA5LjE2IDE4Ljk4IDE0LjExbDEuMDEgMi41Mi0yOS42NSAxMi4yOGMyLjI3IDQuNDUgNS44IDYuNzIgMTAuNzUgNi43MiA0Ljk2IDAgOC40LTIuNDQgMTAuOTItNi4xNHptLTIzLjI3LTcuOThsMTkuODItOC4yM2MtMS4wOS0yLjc3LTQuMzctNC43LTguMjMtNC43LTQuOTUgMC0xMS44NCA0LjM3LTExLjU5IDEyLjkzeiIvPjxwYXRoIGZpbGw9IiM0Mjg1RjQiIGQ9Ik0zNS4yOSA0MS40MVYzMkg2N2MuMzEgMS42NC40NyAzLjU4LjQ3IDUuNjggMCA3LjA2LTEuOTMgMTUuNzktOC4xNSAyMi4wMS02LjA1IDYuMy0xMy43OCA5LjY2LTI0LjAyIDkuNjZDMTYuMzIgNjkuMzUuMzYgNTMuODkuMzYgMzQuOTEuMzYgMTUuOTMgMTYuMzIuNDcgMzUuMy40N2MxMC41IDAgMTcuOTggNC4xMiAyMy42IDkuNDlsLTYuNjQgNi42NGMtNC4wMy0zLjc4LTkuNDktNi43Mi0xNi45Ny02LjcyLTEzLjg2IDAtMjQuNyAxMS4xNy0yNC43IDI1LjAzIDAgMTMuODYgMTAuODQgMjUuMDMgMjQuNyAyNS4wMyA4Ljk5IDAgMTQuMTEtMy42MSAxNy4zOS02Ljg5IDIuNjYtMi42NiA0LjQxLTYuNDYgNS4xLTExLjY1bC0yMi40OS4wMXoiLz48L3N2Zz4=); -webkit-mask-repeat: no-repeat; -webkit-mask-size: 100%; background: #eee; } #logo-default, .non-white-bg #logo-non-white { display: block; } #logo-non-white, .non-white-bg #logo-default { display: none; } #logo-doodle-button { /* An image logo is allowed to spill into the margin below, so it's not a * real bottom margin. If the image extends further than that margin, it * is cropped. */ margin: 0 auto; max-height: calc(var(--logo-height) + var(--logo-margin-bottom)); overflow: hidden; } .non-white-bg #logo-doodle-button, .non-white-bg #logo-doodle-iframe { display: none; } #logo-doodle-iframe { border: 0; height: var(--logo-height); margin: 0 auto var(--logo-margin-bottom) auto; width: var(--content-width); } #logo-doodle-notifier { display: none; } .non-white-bg #logo-doodle-notifier { background: transparent; border: 0; cursor: pointer; display: inline-block; height: 24px; left: calc(50% + 148px); padding: 0; position: absolute; top: 100px; width: 24px; } @keyframes anim-pos { 0% { transform: translate(-98%, 0); } 100% { transform: translate(98%, 0); } } @keyframes anim-z-order { 0% { z-index: 100; } 100% { z-index: 1; } } .non-white-bg #logo-doodle-notifier .outer { animation: anim-z-order 3520ms linear infinite; height: 37.5%; left: 50%; margin-left: -18.75%; margin-top: -18.75%; position: absolute; top: 50%; width: 37.5%; } .non-white-bg #logo-doodle-notifier .inner { animation: anim-pos 880ms cubic-bezier(0.445, 0.05, 0.55, 0.95) infinite alternate; border-radius: 50%; height: 100%; position: absolute; transform: rotate(90deg); width: 100%; } .non-white-bg #logo-doodle-notifier .ball0 { animation-delay: 2640ms; transform: rotate(45deg); } .non-white-bg #logo-doodle-notifier .ball1 { animation-delay: 1760ms; transform: rotate(135deg); } .non-white-bg #logo-doodle-notifier .ball2 { transform: rotate(225deg); } .non-white-bg #logo-doodle-notifier .ball3 { animation-delay: 880ms; transform: rotate(315deg); } .non-white-bg #logo-doodle-notifier .ball0 .inner { background: linear-gradient( 315deg, rgb(0, 85, 221), rgb(0, 119, 255), rgb(0, 119, 255)); } .non-white-bg #logo-doodle-notifier .ball1 .inner { background: linear-gradient( 225deg, rgb(221, 0, 0), rgb(238, 51, 51), rgb(255, 119, 85)); } .non-white-bg #logo-doodle-notifier .ball2 .inner { background: linear-gradient( 90deg, rgb(0, 119, 68), rgb(0, 153, 68), rgb(85, 187, 85)); } .non-white-bg #logo-doodle-notifier .ball3 .inner { background: linear-gradient( 0deg, rgb(255, 170, 51), rgb(255, 204, 0), rgb(255, 221, 102)); } #fakebox-container { margin: 0 auto 8px auto; width: var(--content-width); } #fakebox { background-color: #fff; border-radius: 2px; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08); cursor: text; font-size: 18px; height: 44px; line-height: 36px; margin: 0 calc(var(--tile-margin) / 2 + 1px) 0 calc(var(--tile-margin) / 2); outline: none; position: relative; transition: box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); } .non-google-page #fakebox-container { display: none; } #fakebox:hover, body.fakebox-focused #fakebox { box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.08); } #fakebox > input { bottom: 0; box-sizing: border-box; left: 0; margin: 0; opacity: 0; padding-left: 8px; position: absolute; top: 0; width: 100%; } html[dir=rtl] #fakebox > input { padding-left: 0; padding-right: 8px; right: 0; } #fakebox-text { bottom: 4px; color: rgba(0, 0, 0, 0.38); font-family: arial, sans-serif; font-size: 16px; left: 13px; margin-top: 1px; overflow: hidden; position: absolute; right: 13px; text-align: initial; text-overflow: ellipsis; top: 4px; vertical-align: middle; visibility: inherit; white-space: nowrap; } html[dir=rtl] #fakebox-text { left: auto; right: 13px; } #fakebox-cursor { background: #333; bottom: 12px; left: 13px; position: absolute; top: 12px; visibility: hidden; width: 1px; } html[dir=rtl] #fakebox-cursor { left: auto; right: 13px; } #fakebox-microphone { background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KICAgIDxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiLz4KICAgIDxwYXRoIGZpbGw9IiM0Mjg1RjQiIGQ9Ik0xMiAxNWMxLjY2IDAgMi45OS0xLjM0IDIuOTktM0wxNSA1YzAtMS42Ni0xLjM0LTMtMy0zUzkgMy4zNCA5IDV2N2MwIDEuNjYgMS4zNCAzIDMgM3oiLz4KICAgIDxwYXRoIGZpbGw9IiMzNEE4NTMiIGQ9Ik0xMSAxOC45MmgyVjIyaC0yeiIvPgogICAgPHBhdGggZmlsbD0iI0Y0QjQwMCIgZD0iTTcgMTJINWMwIDEuOTMuNzggMy42OCAyLjA1IDQuOTVsMS40MS0xLjQxQzcuNTYgMTQuNjMgNyAxMy4zOCA3IDEyeiIvPgogICAgPHBhdGggZmlsbD0iI0VBNDMzNSIgZD0iTTEyIDE3Yy0xLjM4IDAtMi42My0uNTYtMy41NC0xLjQ3bC0xLjQxIDEuNDFDOC4zMiAxOC4yMSAxMC4wNyAxOSAxMi4wMSAxOWMzLjg3IDAgNi45OC0zLjE0IDYuOTgtN2gtMmMwIDIuNzYtMi4yMyA1LTQuOTkgNXoiLz4KPC9zdmc+Cg==) no-repeat center; background-size: 24px 24px; bottom: 0; cursor: pointer; padding: 22px 12px 0; position: absolute; right: 0; top: 0; width: 41px; } html[dir=rtl] #fakebox-microphone { left: 0; right: auto; } @keyframes blink { 0% { opacity: 1; } 61.55% { opacity: 0; } } body.fakebox-drag-focused #fakebox-text, body.fakebox-focused #fakebox-text { visibility: hidden; } body.fakebox-drag-focused #fakebox-cursor { visibility: inherit; } body.fakebox-focused #fakebox-cursor { animation: blink 1.3s step-end infinite; visibility: inherit; } #most-visited { height: 100%; margin-top: 56px; max-height: calc(var(--mv-tiles-height) + var(--mv-notice-height)); overflow: hidden; text-align: -webkit-center; user-select: none; } /* Non-Google pages have no Fakebox, so don't need top margin. */ .non-google-page #most-visited { margin-top: 0; } #mv-tiles { height: var(--mv-tiles-height); margin: 0; /* Clamp to the remaining window height minus space for #mv-notice. */ max-height: calc(100% - var(--mv-notice-height)); position: relative; text-align: -webkit-auto; width: var(--content-width); } #mv-notice-x { -webkit-mask-image: -webkit-image-set( url(chrome-search://local-ntp/images/close_3_mask.png) 1x, url(chrome-search://local-ntp/images/close_3_mask.png@2x) 2x); -webkit-mask-position: 3px 3px; -webkit-mask-repeat: no-repeat; -webkit-mask-size: 10px 10px; background-color: rgba(90,90,90,0.7); cursor: pointer; display: inline-block; filter: var(--theme-filter, 'none'); height: 16px; margin-left: 20px; outline: none; vertical-align: middle; width: 16px; } html[dir=rtl] #mv-notice-x { margin-left: 0; margin-right: 20px; } #mv-notice-x:hover { background-color: rgba(90,90,90,1.0); } #mv-notice-x:active { background-color: rgb(66,133,244); } /* The notification shown when a tile is blacklisted. */ #mv-notice { font-size: 12px; font-weight: bold; opacity: 1; padding: 8px 0; } #mv-notice span { cursor: default; display: inline-block; height: 16px; line-height: 16px; vertical-align: top; } /* Links in the notification. */ #mv-notice-links span { -webkit-margin-start: 6px; color: var(--text-color-link); cursor: pointer; outline: none; padding: 0 4px; } #mv-notice-links span:hover, #mv-notice-links span:focus { text-decoration: underline; } #mv-msg { color: var(--text-color); } .default-theme.dark #mv-msg { color: #fff; } .default-theme.dark #mv-notice-links span { color: #fff; } #mv-notice.mv-notice-delayed-hide:not(:focus-within) { opacity: 0; transition-delay: var(--mv-notice-time); transition-property: opacity; } #mv-notice.mv-notice-hide { display: none; } #attribution { bottom: 0; color: var(--text-color-light); cursor: default; display: inline-block; font-size: 13px; left: auto; position: fixed; right: 8px; text-align: left; user-select: none; z-index: -1; } html[dir=rtl] #attribution, #attribution.left-align-attribution { left: 8px; right: auto; text-align: right; } #mv-single { border: none; height: 100%; width: 100%; } #one-google { position: absolute; right: 0; top: 0; transition: opacity 130ms; z-index: 1; } #one-google.hidden { opacity: 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. /** * @fileoverview The local InstantExtended NTP. */ /** * Whether the most visited tiles have finished loading, i.e. we've received the * 'loaded' postMessage from the iframe. Used by tests to detect that loading * has completed. * @type {boolean} */ var tilesAreLoaded = false; /** * Controls rendering the new tab page for InstantExtended. * @return {Object} A limited interface for testing the local NTP. */ function LocalNTP() { 'use strict'; /** * 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) { // eslint-disable-next-line no-restricted-properties return document.getElementById(id); } /** * Specifications for an NTP design (not comprehensive). * * numTitleLines: Number of lines to display in titles. * titleColor: The 4-component color of title text. * titleColorAgainstDark: The 4-component color of title text against a dark * theme. * * @type {{ * numTitleLines: number, * titleColor: string, * titleColorAgainstDark: string, * }} */ var NTP_DESIGN = { numTitleLines: 1, titleColor: [50, 50, 50, 255], titleColorAgainstDark: [210, 210, 210, 255], }; /** * Enum for classnames. * @enum {string} * @const */ var CLASSES = { ALTERNATE_LOGO: 'alternate-logo', // Shows white logo if required by theme DARK: 'dark', DEFAULT_THEME: 'default-theme', DELAYED_HIDE_NOTIFICATION: 'mv-notice-delayed-hide', FADE: 'fade', // Enables opacity transition on logo and doodle. FAKEBOX_FOCUS: 'fakebox-focused', // Applies focus styles to the fakebox // Applies drag focus style to the fakebox FAKEBOX_DRAG_FOCUS: 'fakebox-drag-focused', HIDE_FAKEBOX_AND_LOGO: 'hide-fakebox-logo', HIDE_NOTIFICATION: 'mv-notice-hide', INITED: 'inited', // Reveals the once init() is done. LEFT_ALIGN_ATTRIBUTION: 'left-align-attribution', // Vertically centers the most visited section for a non-Google provided page. NON_GOOGLE_PAGE: 'non-google-page', NON_WHITE_BG: 'non-white-bg', RTL: 'rtl', // Right-to-left language text. SHOW_LOGO: 'show-logo', // Marks logo/doodle that should be shown. }; /** * Enum for HTML element ids. * @enum {string} * @const */ var IDS = { ATTRIBUTION: 'attribution', ATTRIBUTION_TEXT: 'attribution-text', FAKEBOX: 'fakebox', FAKEBOX_INPUT: 'fakebox-input', FAKEBOX_TEXT: 'fakebox-text', FAKEBOX_MICROPHONE: 'fakebox-microphone', LOGO: 'logo', LOGO_DEFAULT: 'logo-default', LOGO_DOODLE: 'logo-doodle', LOGO_DOODLE_IMAGE: 'logo-doodle-image', LOGO_DOODLE_IFRAME: 'logo-doodle-iframe', LOGO_DOODLE_BUTTON: 'logo-doodle-button', LOGO_DOODLE_NOTIFIER: 'logo-doodle-notifier', NOTIFICATION: 'mv-notice', NOTIFICATION_CLOSE_BUTTON: 'mv-notice-x', NOTIFICATION_MESSAGE: 'mv-msg', NTP_CONTENTS: 'ntp-contents', RESTORE_ALL_LINK: 'mv-restore', TILES: 'mv-tiles', TILES_IFRAME: 'mv-single', UNDO_LINK: 'mv-undo' }; /** * Counterpart of search_provider_logos::LogoType. * @enum {string} * @const */ var LOGO_TYPE = { SIMPLE: 'SIMPLE', ANIMATED: 'ANIMATED', INTERACTIVE: 'INTERACTIVE', }; /** * The different types of events that are logged from the NTP. This enum is * used to transfer information from the NTP JavaScript to the renderer and is * not used as a UMA enum histogram's logged value. * Note: Keep in sync with common/ntp_logging_events.h * @enum {number} * @const */ var LOG_TYPE = { // A static Doodle was shown, coming from cache. NTP_STATIC_LOGO_SHOWN_FROM_CACHE: 30, // A static Doodle was shown, coming from the network. NTP_STATIC_LOGO_SHOWN_FRESH: 31, // A call-to-action Doodle image was shown, coming from cache. NTP_CTA_LOGO_SHOWN_FROM_CACHE: 32, // A call-to-action Doodle image was shown, coming from the network. NTP_CTA_LOGO_SHOWN_FRESH: 33, // A static Doodle was clicked. NTP_STATIC_LOGO_CLICKED: 34, // A call-to-action Doodle was clicked. NTP_CTA_LOGO_CLICKED: 35, // An animated Doodle was clicked. NTP_ANIMATED_LOGO_CLICKED: 36, // The One Google Bar was shown. NTP_ONE_GOOGLE_BAR_SHOWN: 37, }; /** * Background colors considered "white". Used to determine if it is possible * to display a Google Doodle, or if the notifier should be used instead. * @type {Array} * @const */ var WHITE_BACKGROUND_COLORS = ['rgba(255,255,255,1)', 'rgba(0,0,0,0)']; /** * Enum for keycodes. * @enum {number} * @const */ var KEYCODE = {ENTER: 13, SPACE: 32}; /** * The last blacklisted tile rid if any, which by definition should not be * filler. * @type {?number} */ var lastBlacklistedTile = null; /** * The browser embeddedSearch.newTabPage object. * @type {Object} */ var ntpApiHandle; /** @type {number} @const */ var MAX_NUM_TILES_TO_SHOW = 8; /** * Returns theme background info, first checking for history.state.notheme. If * the page has notheme set, returns a fallback light-colored theme. */ function getThemeBackgroundInfo() { if (history.state && history.state.notheme) { return { alternateLogo: false, backgroundColorRgba: [255, 255, 255, 255], colorRgba: [255, 255, 255, 255], headerColorRgba: [150, 150, 150, 255], linkColorRgba: [6, 55, 116, 255], sectionBorderColorRgba: [150, 150, 150, 255], textColorLightRgba: [102, 102, 102, 255], textColorRgba: [0, 0, 0, 255], usingDefaultTheme: true, }; } return ntpApiHandle.themeBackgroundInfo; } /** * Heuristic to determine whether a theme should be considered to be dark, so * the colors of various UI elements can be adjusted. * @param {ThemeBackgroundInfo|undefined} info Theme background information. * @return {boolean} Whether the theme is dark. * @private */ function getIsThemeDark() { var info = getThemeBackgroundInfo(); if (!info) return false; // Heuristic: light text implies dark theme. var rgba = info.textColorRgba; var luminance = 0.3 * rgba[0] + 0.59 * rgba[1] + 0.11 * rgba[2]; return luminance >= 128; } /** * Updates the NTP based on the current theme. * @private */ function renderTheme() { var info = getThemeBackgroundInfo(); var isThemeDark = getIsThemeDark(); $(IDS.NTP_CONTENTS).classList.toggle(CLASSES.DARK, isThemeDark); if (!info) { return; } var background = [convertToRGBAColor(info.backgroundColorRgba), info.imageUrl, info.imageTiling, info.imageHorizontalAlignment, info.imageVerticalAlignment].join(' ').trim(); document.body.style.background = background; document.body.classList.toggle(CLASSES.ALTERNATE_LOGO, info.alternateLogo); var isNonWhiteBackground = !WHITE_BACKGROUND_COLORS.includes(background); document.body.classList.toggle(CLASSES.NON_WHITE_BG, isNonWhiteBackground); updateThemeAttribution(info.attributionUrl, info.imageHorizontalAlignment); setCustomThemeStyle(info); // Inform the most visited iframe of the new theme. var themeinfo = {cmd: 'updateTheme'}; themeinfo.isThemeDark = isThemeDark; var titleColor = NTP_DESIGN.titleColor; if (!info.usingDefaultTheme && info.textColorRgba) { titleColor = info.textColorRgba; } else if (isThemeDark) { titleColor = NTP_DESIGN.titleColorAgainstDark; } themeinfo.tileTitleColor = convertToRGBAColor(titleColor); $(IDS.TILES_IFRAME).contentWindow.postMessage(themeinfo, '*'); } /** * Updates the OneGoogleBar (if it is loaded) based on the current theme. * @private */ function renderOneGoogleBarTheme() { if (!window.gbar) { return; } try { var oneGoogleBarApi = window.gbar.a; var oneGoogleBarPromise = oneGoogleBarApi.bf(); oneGoogleBarPromise.then(function(oneGoogleBar) { var isThemeDark = getIsThemeDark(); var setForegroundStyle = oneGoogleBar.pc.bind(oneGoogleBar); setForegroundStyle(isThemeDark ? 1 : 0); }); } catch (err) { console.log('Failed setting OneGoogleBar theme:\n' + err); } } /** * Callback for embeddedSearch.newTabPage.onthemechange. * @private */ function onThemeChange() { renderTheme(); renderOneGoogleBarTheme(); } /** * Updates the NTP style according to theme. * @param {Object} themeInfo The information about the theme. * @private */ function setCustomThemeStyle(themeInfo) { var textColor = null; var textColorLight = null; var mvxFilter = null; if (!themeInfo.usingDefaultTheme) { textColor = convertToRGBAColor(themeInfo.textColorRgba); textColorLight = convertToRGBAColor(themeInfo.textColorLightRgba); mvxFilter = 'drop-shadow(0 0 0 ' + textColor + ')'; } $(IDS.NTP_CONTENTS) .classList.toggle(CLASSES.DEFAULT_THEME, themeInfo.usingDefaultTheme); document.body.style.setProperty('--text-color', textColor); document.body.style.setProperty('--text-color-light', textColorLight); // Themes reuse the "light" text color for links too. document.body.style.setProperty('--text-color-link', textColorLight); $(IDS.NOTIFICATION_CLOSE_BUTTON) .style.setProperty('--theme-filter', mvxFilter); } /** * Renders the attribution if the URL is present, otherwise hides it. * @param {string} url The URL of the attribution image, if any. * @param {string} themeBackgroundAlignment The alignment of the theme * background image. This is used to compute the attribution's alignment. * @private */ function updateThemeAttribution(url, themeBackgroundAlignment) { if (!url) { setAttributionVisibility_(false); return; } var attribution = $(IDS.ATTRIBUTION); var attributionImage = attribution.querySelector('img'); if (!attributionImage) { attributionImage = new Image(); attribution.appendChild(attributionImage); } attributionImage.style.content = url; // To avoid conflicts, place the attribution on the left for themes that // right align their background images. attribution.classList.toggle(CLASSES.LEFT_ALIGN_ATTRIBUTION, themeBackgroundAlignment == 'right'); setAttributionVisibility_(true); } /** * Sets the visibility of the theme attribution. * @param {boolean} show True to show the attribution. * @private */ function setAttributionVisibility_(show) { $(IDS.ATTRIBUTION).style.display = show ? '' : 'none'; } /** * Converts an Array of color components into RGBA format "rgba(R,G,B,A)". * @param {Array} color Array of rgba color components. * @return {string} CSS color in RGBA format. * @private */ function convertToRGBAColor(color) { return 'rgba(' + color[0] + ',' + color[1] + ',' + color[2] + ',' + color[3] / 255 + ')'; } /** * Callback for embeddedSearch.newTabPage.onmostvisitedchange. Called when the * NTP tiles are updated. */ function onMostVisitedChange() { reloadTiles(); } /** * Fetches new data (RIDs) from the embeddedSearch.newTabPage API and passes * them to the iframe. */ function reloadTiles() { // Don't attempt to load tiles if the MV data isn't available yet - this can // happen occasionally, see https://crbug.com/794942. In that case, we should // get an onMostVisitedChange call once they are available. // Note that MV data being available is different from having > 0 tiles. There // can legitimately be 0 tiles, e.g. if the user blacklisted them all. if (!ntpApiHandle.mostVisitedAvailable) { return; } var pages = ntpApiHandle.mostVisited; var cmds = []; for (var i = 0; i < Math.min(MAX_NUM_TILES_TO_SHOW, pages.length); ++i) { cmds.push({cmd: 'tile', rid: pages[i].rid}); } cmds.push({cmd: 'show'}); $(IDS.TILES_IFRAME).contentWindow.postMessage(cmds, '*'); } /** * Shows the blacklist notification and triggers a delay to hide it. */ function showNotification() { var notification = $(IDS.NOTIFICATION); notification.classList.remove(CLASSES.HIDE_NOTIFICATION); notification.classList.remove(CLASSES.DELAYED_HIDE_NOTIFICATION); notification.scrollTop; notification.classList.add(CLASSES.DELAYED_HIDE_NOTIFICATION); } /** * Hides the blacklist notification. */ function hideNotification() { var notification = $(IDS.NOTIFICATION); notification.classList.add(CLASSES.HIDE_NOTIFICATION); notification.classList.remove(CLASSES.DELAYED_HIDE_NOTIFICATION); } /** * Handles a click on the notification undo link by hiding the notification and * informing Chrome. */ function onUndo() { hideNotification(); if (lastBlacklistedTile != null) { ntpApiHandle.undoMostVisitedDeletion(lastBlacklistedTile); } } /** * Handles a click on the restore all notification link by hiding the * notification and informing Chrome. */ function onRestoreAll() { hideNotification(); ntpApiHandle.undoAllMostVisitedDeletions(); } /** * Callback for embeddedSearch.newTabPage.oninputstart. Handles new input by * disposing the NTP, according to where the input was entered. */ function onInputStart() { if (isFakeboxFocused()) { setFakeboxFocus(false); setFakeboxDragFocus(false); setFakeboxAndLogoVisibility(false); } } /** * Callback for embeddedSearch.newTabPage.oninputcancel. Restores the NTP * (re-enables the fakebox and unhides the logo.) */ function onInputCancel() { setFakeboxAndLogoVisibility(true); } /** * @param {boolean} focus True to focus the fakebox. */ function setFakeboxFocus(focus) { document.body.classList.toggle(CLASSES.FAKEBOX_FOCUS, focus); } /** * @param {boolean} focus True to show a dragging focus on the fakebox. */ function setFakeboxDragFocus(focus) { document.body.classList.toggle(CLASSES.FAKEBOX_DRAG_FOCUS, focus); } /** * @return {boolean} True if the fakebox has focus. */ function isFakeboxFocused() { return document.body.classList.contains(CLASSES.FAKEBOX_FOCUS) || document.body.classList.contains(CLASSES.FAKEBOX_DRAG_FOCUS); } /** * @param {!Event} event The click event. * @return {boolean} True if the click occurred in an enabled fakebox. */ function isFakeboxClick(event) { return $(IDS.FAKEBOX).contains(event.target) && !$(IDS.FAKEBOX_MICROPHONE).contains(event.target); } /** * @param {boolean} show True to show the fakebox and logo. */ function setFakeboxAndLogoVisibility(show) { document.body.classList.toggle(CLASSES.HIDE_FAKEBOX_AND_LOGO, !show); } /** * @param {!Element} element The element to register the handler for. * @param {number} keycode The keycode of the key to register. * @param {!Function} handler The key handler to register. */ function registerKeyHandler(element, keycode, handler) { element.addEventListener('keydown', function(event) { if (event.keyCode == keycode) handler(event); }); } /** * Event handler for messages from the most visited iframe. * @param {Event} event Event received. */ function handlePostMessage(event) { var cmd = event.data.cmd; var args = event.data; if (cmd === 'loaded') { tilesAreLoaded = true; if (configData.isGooglePage && !$('one-google-loader')) { // Load the OneGoogleBar script. It'll create a global variable name "og" // which is a dict corresponding to the native OneGoogleBarData type. // We do this only after all the tiles have loaded, to avoid slowing down // the main page load. var ogScript = document.createElement('script'); ogScript.id = 'one-google-loader'; ogScript.src = 'chrome-search://local-ntp/one-google.js'; document.body.appendChild(ogScript); ogScript.onload = function() { injectOneGoogleBar(og); }; } } else if (cmd === 'tileBlacklisted') { showNotification(); lastBlacklistedTile = args.tid; ntpApiHandle.deleteMostVisitedItem(args.tid); } else if (cmd === 'resizeDoodle') { let width = args.width || null; let height = args.height || null; let duration = args.duration || '0s'; let iframe = $(IDS.LOGO_DOODLE_IFRAME); iframe.style.transition = 'width ' + duration + ', height ' + duration; iframe.style.width = width; iframe.style.height = height; } } /** * Prepares the New Tab Page by adding listeners, the most visited pages * section, and Google-specific elements for a Google-provided page. */ function init() { // If an accessibility tool is in use, increase the time for which the // "tile was blacklisted" notification is shown. if (configData.isAccessibleBrowser) { document.body.style.setProperty('--mv-notice-time', '30s'); } // Hide notifications after fade out, so we can't focus on links via keyboard. $(IDS.NOTIFICATION).addEventListener('transitionend', hideNotification); $(IDS.NOTIFICATION_MESSAGE).textContent = configData.translatedStrings.thumbnailRemovedNotification; var undoLink = $(IDS.UNDO_LINK); undoLink.addEventListener('click', onUndo); registerKeyHandler(undoLink, KEYCODE.ENTER, onUndo); registerKeyHandler(undoLink, KEYCODE.SPACE, onUndo); undoLink.textContent = configData.translatedStrings.undoThumbnailRemove; var restoreAllLink = $(IDS.RESTORE_ALL_LINK); restoreAllLink.addEventListener('click', onRestoreAll); registerKeyHandler(restoreAllLink, KEYCODE.ENTER, onRestoreAll); registerKeyHandler(restoreAllLink, KEYCODE.SPACE, onRestoreAll); restoreAllLink.textContent = configData.translatedStrings.restoreThumbnailsShort; $(IDS.ATTRIBUTION_TEXT).textContent = configData.translatedStrings.attributionIntro; $(IDS.NOTIFICATION_CLOSE_BUTTON).addEventListener('click', hideNotification); var embeddedSearchApiHandle = window.chrome.embeddedSearch; ntpApiHandle = embeddedSearchApiHandle.newTabPage; ntpApiHandle.onthemechange = onThemeChange; ntpApiHandle.onmostvisitedchange = onMostVisitedChange; var searchboxApiHandle = embeddedSearchApiHandle.searchBox; if (configData.isGooglePage) { // Set up the fakebox (which only exists on the Google NTP). ntpApiHandle.oninputstart = onInputStart; ntpApiHandle.oninputcancel = onInputCancel; if (ntpApiHandle.isInputInProgress) { onInputStart(); } $(IDS.FAKEBOX_TEXT).textContent = configData.translatedStrings.searchboxPlaceholder; if (configData.isVoiceSearchEnabled) { speech.init( configData.googleBaseUrl, configData.translatedStrings, $(IDS.FAKEBOX_MICROPHONE), searchboxApiHandle); } // Listener for updating the key capture state. document.body.onmousedown = function(event) { if (isFakeboxClick(event)) searchboxApiHandle.startCapturingKeyStrokes(); else if (isFakeboxFocused()) searchboxApiHandle.stopCapturingKeyStrokes(); }; searchboxApiHandle.onkeycapturechange = function() { setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); }; var inputbox = $(IDS.FAKEBOX_INPUT); inputbox.onpaste = function(event) { event.preventDefault(); // Send pasted text to Omnibox. var text = event.clipboardData.getData('text/plain'); if (text) searchboxApiHandle.paste(text); }; inputbox.ondrop = function(event) { event.preventDefault(); var text = event.dataTransfer.getData('text/plain'); if (text) { searchboxApiHandle.paste(text); } setFakeboxDragFocus(false); }; inputbox.ondragenter = function() { setFakeboxDragFocus(true); }; inputbox.ondragleave = function() { setFakeboxDragFocus(false); }; // Update the fakebox style to match the current key capturing state. setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); // Also tell the browser that we're capturing, otherwise it's possible that // both fakebox and Omnibox have visible focus at the same time, see // crbug.com/792850. if (searchboxApiHandle.isKeyCaptureEnabled) { searchboxApiHandle.startCapturingKeyStrokes(); } // Load the Doodle. After the first request completes (getting cached // data), issue a second request for fresh Doodle data. loadDoodle(/*v=*/null, function(ddl) { if (ddl === null) { // Got no ddl object at all, the feature is probably disabled. Just show // the logo. showLogoOrDoodle(null, null, /*fromCache=*/true); return; } // Got a (possibly empty) ddl object. Show logo or doodle. showLogoOrDoodle( ddl.image || null, ddl.metadata || null, /*fromCache=*/true); // Never hide an interactive doodle if it was already shown. if (ddl.metadata && (ddl.metadata.type === LOGO_TYPE.INTERACTIVE)) return; // If we got a valid ddl object (from cache), load a fresh one. if (ddl.v !== null) { loadDoodle(ddl.v, function(ddl) { if (ddl.usable) { fadeToLogoOrDoodle(ddl.image, ddl.metadata); } }); } }); // Set up doodle notifier (but it may be invisible). var doodleNotifier = $(IDS.LOGO_DOODLE_NOTIFIER); doodleNotifier.title = configData.translatedStrings.clickToViewDoodle; doodleNotifier.addEventListener('click', function(e) { e.preventDefault(); var state = window.history.state || {}; state.notheme = true; window.history.replaceState(state, document.title); onThemeChange(); if (e.detail === 0) { // Activated by keyboard. $(IDS.LOGO_DOODLE_BUTTON).focus(); } }); } else { document.body.classList.add(CLASSES.NON_GOOGLE_PAGE); } if (searchboxApiHandle.rtl) { $(IDS.NOTIFICATION).dir = 'rtl'; // Grabbing the root HTML element. document.documentElement.setAttribute('dir', 'rtl'); // Add class for setting alignments based on language directionality. document.documentElement.classList.add(CLASSES.RTL); } // Collect arguments for the most visited iframe. var args = []; if (searchboxApiHandle.rtl) args.push('rtl=1'); if (NTP_DESIGN.numTitleLines > 1) args.push('ntl=' + NTP_DESIGN.numTitleLines); args.push('removeTooltip=' + encodeURIComponent(configData.translatedStrings.removeThumbnailTooltip)); // Create the most visited iframe. var iframe = document.createElement('iframe'); iframe.id = IDS.TILES_IFRAME; iframe.name = IDS.TILES_IFRAME; iframe.title = configData.translatedStrings.mostVisitedTitle; iframe.src = 'chrome-search://most-visited/single.html?' + args.join('&'); $(IDS.TILES).appendChild(iframe); iframe.onload = function() { reloadTiles(); renderTheme(); }; window.addEventListener('message', handlePostMessage); document.body.classList.add(CLASSES.INITED); } function loadConfig() { var configScript = document.createElement('script'); configScript.type = 'text/javascript'; configScript.src = 'chrome-search://local-ntp/config.js'; configScript.onload = init; document.head.appendChild(configScript); } /** * Binds event listeners. */ function listen() { document.addEventListener('DOMContentLoaded', loadConfig); } /** * Injects the One Google Bar into the page. Called asynchronously, so that it * doesn't block the main page load. */ function injectOneGoogleBar(ogb) { var inHeadStyle = document.createElement('style'); inHeadStyle.type = 'text/css'; inHeadStyle.appendChild(document.createTextNode(ogb.inHeadStyle)); document.head.appendChild(inHeadStyle); var inHeadScript = document.createElement('script'); inHeadScript.type = 'text/javascript'; inHeadScript.appendChild(document.createTextNode(ogb.inHeadScript)); document.head.appendChild(inHeadScript); renderOneGoogleBarTheme(); var ogElem = $('one-google'); ogElem.innerHTML = ogb.barHtml; ogElem.classList.remove('hidden'); var afterBarScript = document.createElement('script'); afterBarScript.type = 'text/javascript'; afterBarScript.appendChild(document.createTextNode(ogb.afterBarScript)); ogElem.parentNode.insertBefore(afterBarScript, ogElem.nextSibling); $('one-google-end-of-body').innerHTML = ogb.endOfBodyHtml; var endOfBodyScript = document.createElement('script'); endOfBodyScript.type = 'text/javascript'; endOfBodyScript.appendChild(document.createTextNode(ogb.endOfBodyScript)); document.body.appendChild(endOfBodyScript); ntpApiHandle.logEvent(LOG_TYPE.NTP_ONE_GOOGLE_BAR_SHOWN); } /** Loads the Doodle. On success, the loaded script declares a global variable * ddl, which onload() receives as its single argument. On failure, onload() is * called with null as the argument. If v is null, then the call requests a * cached logo. If non-null, it must be the ddl.v of a previous request for a * cached logo, and the corresponding fresh logo is returned. * @param {?number} v * @param {function(?{v, usable, image, metadata})} onload */ var loadDoodle = function(v, onload) { var ddlScript = document.createElement('script'); ddlScript.src = 'chrome-search://local-ntp/doodle.js'; if (v !== null) ddlScript.src += '?v=' + v; ddlScript.onload = function() { onload(ddl); }; ddlScript.onerror = function() { onload(null); }; // TODO(treib,sfiera): Add a timeout in case something goes wrong? document.body.appendChild(ddlScript); }; /** Returns true if the doodle given by |image| and |metadata| is currently * visible. If |image| is null, returns true when the default logo is visible; * if non-null, checks that it matches the doodle that is currently visible. * Here, "visible" means fully-visible or fading in. * * @param {?Object} image * @param {?Object} metadata * @returns {boolean} */ var isDoodleCurrentlyVisible = function(image, metadata) { var haveDoodle = ($(IDS.LOGO_DOODLE).classList.contains(CLASSES.SHOW_LOGO)); var wantDoodle = (image !== null) && (metadata !== null); if (!haveDoodle || !wantDoodle) return haveDoodle === wantDoodle; // Have a visible doodle and a query doodle. Test that they match. if (metadata.type === LOGO_TYPE.INTERACTIVE) { var logoDoodleIframe = $(IDS.LOGO_DOODLE_IFRAME); return logoDoodleIframe.classList.contains(CLASSES.SHOW_LOGO) && (logoDoodleIframe.src === metadata.fullPageUrl); } else { var logoDoodleImage = $(IDS.LOGO_DOODLE_IMAGE); var logoDoodleButton = $(IDS.LOGO_DOODLE_BUTTON); return logoDoodleButton.classList.contains(CLASSES.SHOW_LOGO) && ((logoDoodleImage.src === image) || (logoDoodleImage.src === metadata.animatedUrl)); } }; var showLogoOrDoodle = function(image, metadata, fromCache) { if (metadata !== null) { applyDoodleMetadata(metadata); if (metadata.type === LOGO_TYPE.INTERACTIVE) { $(IDS.LOGO_DOODLE_BUTTON).classList.remove(CLASSES.SHOW_LOGO); $(IDS.LOGO_DOODLE_IFRAME).classList.add(CLASSES.SHOW_LOGO); } else { $(IDS.LOGO_DOODLE_IMAGE).src = image; $(IDS.LOGO_DOODLE_BUTTON).classList.add(CLASSES.SHOW_LOGO); $(IDS.LOGO_DOODLE_IFRAME).classList.remove(CLASSES.SHOW_LOGO); var isCta = !!metadata.animatedUrl; var eventType = isCta ? (fromCache ? LOG_TYPE.NTP_CTA_LOGO_SHOWN_FROM_CACHE : LOG_TYPE.NTP_CTA_LOGO_SHOWN_FRESH) : (fromCache ? LOG_TYPE.NTP_STATIC_LOGO_SHOWN_FROM_CACHE : LOG_TYPE.NTP_STATIC_LOGO_SHOWN_FRESH); ntpApiHandle.logEvent(eventType); } $(IDS.LOGO_DOODLE).classList.add(CLASSES.SHOW_LOGO); } else { $(IDS.LOGO_DEFAULT).classList.add(CLASSES.SHOW_LOGO); } }; /** The image and metadata that should be shown, according to the latest fetch. * After a logo fades out, onDoodleFadeOutComplete fades in a logo according to * targetDoodle. */ var targetDoodle = { image: null, metadata: null, }; /** * Starts fading out the given element, which should be either the default logo * or the doodle. * * @param {HTMLElement} element */ var startFadeOut = function(element) { if (!element.classList.contains(CLASSES.SHOW_LOGO)) { return; } // Compute style now, to ensure that the transition from 1 -> 0 is properly // recognized. Otherwise, if a 0 -> 1 -> 0 transition is too fast, the // element might stay invisible instead of appearing then fading out. window.getComputedStyle(element).opacity; element.classList.add(CLASSES.FADE); element.classList.remove(CLASSES.SHOW_LOGO); element.addEventListener('transitionend', onDoodleFadeOutComplete); }; /** * Integrates a fresh doodle into the page as appropriate. If the correct logo * or doodle is already shown, just updates the metadata. Otherwise, initiates * a fade from the currently-shown logo/doodle to the new one. * * @param {?Object} image * @param {?Object} metadata */ var fadeToLogoOrDoodle = function(image, metadata) { // If the image is already visible, there's no need to start a fade-out. // However, metadata may have changed, so update the doodle's alt text and // href, if applicable. if (isDoodleCurrentlyVisible(image, metadata)) { if (metadata !== null) { applyDoodleMetadata(metadata); } return; } // Set the target to use once the current logo/doodle has finished fading out. targetDoodle.image = image; targetDoodle.metadata = metadata; // Start fading out the current logo or doodle. onDoodleFadeOutComplete will // apply the change when the fade-out finishes. startFadeOut($(IDS.LOGO_DEFAULT)); startFadeOut($(IDS.LOGO_DOODLE)); }; var onDoodleFadeOutComplete = function(e) { // Fade-out finished. Start fading in the appropriate logo. $(IDS.LOGO_DOODLE).classList.add(CLASSES.FADE); $(IDS.LOGO_DEFAULT).classList.add(CLASSES.FADE); showLogoOrDoodle( targetDoodle.image, targetDoodle.metadata, /*fromCache=*/false); this.removeEventListener('transitionend', onDoodleFadeOutComplete); }; var applyDoodleMetadata = function(metadata) { var logoDoodleButton = $(IDS.LOGO_DOODLE_BUTTON); var logoDoodleImage = $(IDS.LOGO_DOODLE_IMAGE); var logoDoodleIframe = $(IDS.LOGO_DOODLE_IFRAME); switch (metadata.type) { case LOGO_TYPE.SIMPLE: logoDoodleImage.title = metadata.altText; logoDoodleButton.onclick = function() { ntpApiHandle.logEvent(LOG_TYPE.NTP_STATIC_LOGO_CLICKED); window.location = metadata.onClickUrl; }; break; case LOGO_TYPE.ANIMATED: logoDoodleImage.title = metadata.altText; logoDoodleButton.onclick = function(e) { ntpApiHandle.logEvent(LOG_TYPE.NTP_CTA_LOGO_CLICKED); e.preventDefault(); logoDoodleImage.src = metadata.animatedUrl; logoDoodleButton.onclick = function() { ntpApiHandle.logEvent(LOG_TYPE.NTP_ANIMATED_LOGO_CLICKED); window.location = metadata.onClickUrl; }; }; break; case LOGO_TYPE.INTERACTIVE: logoDoodleIframe.title = metadata.altText; logoDoodleIframe.src = metadata.fullPageUrl; break; } }; return { init: init, // Exposed for testing. listen: listen }; } if (!window.localNTPUnitTest) { LocalNTP().listen(); } PNG  IHDR@@iq IDATxtFo$SēaZ𘙙233 $C&~rc'}?s%WUj8p\Z) (26Mi6z;V̕rA${Cj*0"Q@g.2˟ IT. W~` 7TƆJAR}k'/jtjj HX>j`̇뻀@GTh$n (;tL)W``Ƚ_=xdG5/P#g DMؕ sb)w>thƺ/"( bpKbNί5  3#g# V 81**F2lcY.?4bYzsEPTm*]iĺWHj5?df?Qd~'noNUEF7mF=vwJ~>A*zv’auS@1]g푣zӝioME/I/i hkTPAD`{}]1 06qtӒ+3mkK:UTj6 |'wźPTn9ɥLc˓_嗮+HԵ)"@qȭӟ!odstuTtҋ_R.Thl/D#/h*C#! rwJ VrjpFGTj/O7S;$L4d cZ"0F0AP?I`Xd aSն9a* +Wᆝ@6, jЩ]о('ǫpg \ WmmUZa%pZ@Tm `p >ϒ?ClX 0@<,{?6o>8Np @ m^A ߹+m ^I8b11*<~+7sōIhEA7G_ 8'@>-x}Y?+JWv|W^|#H&ʃJ;󶧯nWgwOQq/g7/7{YvO蓁ñe7"J.|SPS^`-6iNq~BW=n7Wi' _'qŜ`>0N4n_Ahcϛ<6MG*Lǁ=hݤfYFCmt.)ct(vvVy{9[]?jjN%03ښOa3ye KHD'L=@t*=ɍd(dCf=_4܊onPnPX\ҖeH iUhV.fZI>f,T!>|MxѰ*M{ }r!TL  &`kߚ9p`?Tʿ5ט('LAxsٵ|ȞHIu~HQXX7?.6GKkӔ{rЅJ8jjei48_䆭 Y'a3$o4p[SFu@vC[k YB@+ nlZêZ˺FP15BctA=s_bO_lb1j-I*Mr*bwMZ}t X[[Oxv9@8TTYI#VNE7&mަt/:eX4o.n:D;8d|Mw?|FA {Aog-8U\8.KH˼yjT$ベ\$}$WIUD"$_=5'ߕ^2pGL&mAW'hb8NW1s3_38ȵR^2>襓NXsm$'q=l<{sT92Nd04pRMwY|jcq<Ԓ8G,kW :ήf#>(〛CdIOܡaV(~n/|]J Xa uIDATxpVJjrm&Md 󘙙qXd7Kd *-r=$W9:Rw8qs89sYu?}H:0xnx ,"ۘVs8=!^Qų@vVU2n;È%DցS\D _ͺGӝgwJ~~Mg7q(O(`=|Ç } uzc8p j tle5M?r?: W08dsx_]{r'.r}p V16xH~3Y)`O JȌ PPS\0 ^{j!M nXa3.  zE 81B.9{+o>`PW1hAfCSq%MvϚ| Iwˀ1 J|gOZ3 M( gk}DQ?A juk3 | E?r"!3ITncQw*r#ruSXiϢڡJh!o?)긣8'+)yOc0t®z (-v41U]J:.z\b:&4.V̿u;E?ђڧSAox6Z#.zS'JJȬ$tkcM̛f\UP4wBjYav#;F6+ft#T\Npę9&lDQeVTS6dF _,U*#rIr ^2?luE\6*@OP͕zte*YdxNr$-nמ֯u{H65ٰ#LUL~"|X~zu/ }Dͯz~ d E>4l MOD#gn:ӭp{͹9G(Z{ ɦ@q{2k6)ssN[ic7P$Kt4b9Kyt8n q",fdnN>GPUٌ =0`3l?ORez͡? 7#:q-q{* 2X!sYNuEՕ<`u7}Q""_lʎP )}}HHnW 6YHev5EKDp1N]%IJg @0F&{ {Z9a O=ZJ (WՖR}}|#5D*[ _G'R@݄1^qzjU 膎;:{ҍ5Q" rJߩ ̎+iФ^@{ȓV jP7>b{`{ rwYP0~bp( @aCGd apqU~h fbHI\(h>#/$2iCv)_f6t; 2OWg1UŚ*;O{<8Cɲ>fcZR CifL}F:ot"xLvjlZm$)b+ 9#>ASC1ȒD8sUtP%M\r ۷ p?S3n6Lʝf xVFW!\X?:BNxAAxg A]gΏ %ͷ? q㵧1?>G1_e|9+n. N't ާeE \<_#(I4H]e,\?BEsrƗ/[0ǶդHX2綽8@Cz@~1W)vL(#'̛ eM>xp?|pNciG^fHavyyÒ |çEAnBۆ3: 3' :r_gIq.xjqQ;f83#~o|BUP=͇%>Nuwhrp"t8x DL}TFL*9^cic*]1tKc8ԽOfΘh(v.j5[Q9 =Og8|Sa;(K!:HYS氦ib鼆K$3xʛ- O63ݐdY.,1x:u'9*. QGf|p8)4(>د%⡏>@)UvT57SM&KavJW1yóc18rM"擙o>*bd&&6A7tsvs<>H`m~elYDR,&3WӠ`VsȍI3<3AaW}mPIDLѠqBTh~V'3e8(9*Tl 4sbU=z̗%3EYo:Q?2E<t;q X R CᛯӊWL_+ Vg뎍8Uferb7ae]*6]Z {׮oECzTX| g|1:^̋|U!R4~6NDLѠɚMi4S8/NJZD Kys?-8,ci~ =0 %4^APFUfqEx7?B3!D bGVsw-?u~W?w's8R+a;+S4&zLlٹ]Ra>oC ~XvNDL`{ڻGu@+ʆ[Iz[7?3OQMWn+S4F.t̋zd=Esg =xB/m` /ODʤs$.8cϝS~?K+f~ʍaa?6k'E2P>,\x92_'F/zA0LT{Q$3@J\VG*5Rl|%N YO8::ᓉ(oGW]AQn'=߽?_~o(@&Hfןn<ЅLuo:03Uϧ}OMC鋪8J@"dҍB9["|iY"C40~9 E}-] ?ҟa|`"B;5* vO fkU0>E?pS]O? ب'jqe쏭:vD!yKdLxAdO#8y;ӨV?$ͱ{ZUnR < D$bz`7|n= 1^pNAYS0|,lXH. *A[ j|e7*t0RRF2ݷ ~O'ٲi_|>0Bܱ5aHέ;÷]kXP*ܙ(i$ypnyN=wcA?7>]^z_2vWin:,e1>r׫Se|7(gXZCajC+"{oRKrm^iNuGvQs3,"`2XKp}pu(U7c-!17UMV Sp͙p)Jc -v]m&gu5Ċ_f P?F(CB^PI{c.>q 0`Ex#nhՉX+>Q:wU?$/t(WT&\0TK<8e5['o̞*.O<徯Zzu,q7tHTKE3--B9PUsuJvZި vm}eNptm@fEOiPksXٱX ط˭XL,]}/tBq3S#̋\tcٺ:6 a覗p,sMA |4VT`_"VB&X]AN6VTǩeS\Ӳ>t@k̟Q[+&rL7u.e.?rJԩs4UEW] ߣ,Nԍ߉XItcEuկ[ltnȿTf"D1(;6ЍTSnտMĊ+Xq]}FG+Aiɷ86QqEiO~;?N dҍAh%'vWXtߦ{Ł:k}7zz:4ēMĠdҍviEu%4ԹD nӻ}}ښ# > kkF[o;S:eA)΂(8k9&gp%_Yk3:l4UXs(de߱,iߙvͅ6hKı'ǒ?(ƞ~**t-iũwC\㢐+vaC0>%=9Օ_1DTasZ+jxFZKR#%/tggQLkF훀bX|s#1沠RakcN_J'v_:2ydSVo>:@̐={az&*:ZePdw&8w,-W^xUN84uI!Z]zsETXi9P7\mM[t [͟>*AmV_X聿cK(Yүk"u]8ivƧ&Ԫ7Vn7C'y7vwkDLD[\{vZ]4:GT?F[|(7Sߪ_1VN5xAY/lv\~'jqy{t. Cv방UL])=k_}bLjt/]/M.JY nD+ [Um:n3+byfų^=_.vdJ/]:(" c"֪!]kFm_xyMW;8#x (DQl1Ha*.]u'^w J|l>񉶿;vjFF T}Nc_DT=5t+OF&~ruO!VpsW9p\ฎqKzIENDB`PNG  IHDR\rfbKGD pHYs B(xtIME 6z0 IDATx}yey33$L2Ñp r(+ YYDߊ Ჺ(^x  r@L2Lu鞩|H2]us?/I$DI$DI$DI$DI$DI$DI$DI$DI$DI$P[>|E[G|Vij_cd~֍GO1"HL^ѭ%˱H Xļ4,#^ja1c\A1VEI]M:6م3z+Dxgp1`q.p 1Q G2$3*BQ\4Q\B4-h:!⚀&򫈔T,('#=x]tň"R.n۠45:A pX6ôqTPjf꽚1W@\iX @\²1Ư2B춫7rD?#;#& ^*k,Ș `Y `!t$PgQ\+!.H$)żvR?_hD0yT[5AC!k*d-RP<;Vz@YkdB J $d% !B+?P.t z5#mH SƌG 7~JjH vV Gk'"}qՉ(XiXTV"eHX6@saԃ z;sdHuT?s͆@Kȅm],֤ ɌDPg VvNNjL(TUU@_X oQ& 4 ǵb. 4l?hE0y3:ŇTVb<-1БXХST'kUڼh5F09O,\qYBɴXg MVTx߭K@*iJkc_ G@2t]|Oi}f1P͇4ץ[ ڌ;y(ֈB6?ݕ&LJ%&ʷ!j6mz4,Ѡ-)2a??D0WqjtO8K&LfTE&0  G1AM_o(F@} ?m9j踃TVUk8t0@CLVk}=ZL+֗%O8؟ફZ h8T!u,ՠߗ56?Zc?tqDKmf_:D3#< ,ۥR:Y"*(I3{8ZjiJ7U.;Ktbb4u߈`E_zfub|︍ aAFq\8|Sm_xc0 #௚Q˩݄Oc!k),b(z~D.=e#m L%)ݾqf5t P?3L~m׼@nv^.<}{8|r$ւl8@;䚄utwcv._%"67?uWbo#*R @8V|'?K`I_2F6o"hqy 4lG Q*q̈e1$bIRx7^d;㣭}*w5a9*}ASSflcxZϼ-ZM6ktM\gԢQ4)|"P @Hn@ l> +=X)ok?ͤ"hrFqQ %.,"a84JooV"wzz?ڼvۭ'ʽ}w+=.} +`?Y8qQiگi4\wϼ<>VT.H`-",ZG67S;-\ęk50:.%1س*f-FQ}FC$9 붞n~qHIY<`XLjrV@Nsy+`&}(W‹cΐSɌ( 5=ǔ+sc;HOf$Oڇ%ߎ yOݘvY@s.N@6=6@f lh=vNڽ;FnG[( @E_IJu! K%$ǹ@Eq0-H3"hX㮹un]'Zי^,ӆx:un3jT5?8Gզiƪ|74;c>{uݚ '}b.c:SL)FR r>4h{Q͌0RG W"pJyl.K冀љ vI;zym={^^}v >cX_`4Т'ػ7w$a'0~kvhD ªˣWwC GSsVti@ ̕J7YC=M9f{F&SUsI;fL>"%JKtɝ2-X. v b>O`c6Riw_xîvBqP شG~_lZ@Ŵ'He6)҂cܳ7A8ŬV6tc G'87yn^X =v|"e{X)-[c3 5B̒bQ7/ oM~uGd&J`e] ?~y`a%Ӈ؍N%6cx+k)H3λw5|p܎Ӳ(+fUi_b`9@LUCPJG  h$nې--ᄡe+7P iWG /1+`!v|݂'HZ^j#evL]זpɿOfIڍP] QN1S'Iy 9+[rTؐN ?)B #}7uRe큝+YS>@Ar`J~K+JdULf{nl+?'U*-!pJNIPF{W'P '<)Vw|4k)!dg)~1eu'K8;~dr@/m(3cʀ M+}/2 + tߤN&?uɖ'˘*=f vskN  $c(0)R*c(eK([# D`qUMSĪܽyPUy/jF*>_*\wxN,~ջq8sE("tqy겻k[76I?3(pO-^xi>~ZUa+;1+҅Xap68MIeAI pܡx 8aceTزm<OnKA*6Pmx: `-Xy^G |'B9%#ܻ8;gpr M9)@VDͪYzgv:ʶ b Щ[,7 Pt!SG_(dn<]ĹR\1 Xytl6]ىӏ_ӏYmΣ՚&qC6nC֐'ck2 9_x:9䍯rA] yxy*Tbx˻3)gCͽ<A ~ӟi" _)nz o<\޽,u L0!"O3>A1Dt1PdQd$i^5C-ڵ'_޷?k9p@kygr牥0Uh1 $ l'?P$PدBx, d|Q٠˅@Q`+ `[oYSC;c[qv#k24] XޠmxQ1qfJxO, ΃]~R GZ8x#!Б%jJ Ю4f_ݒ\ЇE˖`7CZYp7bo%cw7,fr ^s|?.jtQŽBg@O_!_.A*%3LʯݽRsrQ`t@h'nʮxn"DE- xD+P~?`@p "ŔV@1\&-eP⼕8n'ٺ= 0 H"8(`iBћ/$4 h(-)]*CB%zK7) ,:4TP3BQZ6³[䴨 P¾ tV1A ":oK#$q{Waao*{'=IȘd-:⌮8Й t'KzQ +q;රwaxվbx{ YMנ +:X4]D{~\&u0l/ ME ߶plr"LqswxI A*,\O˼wx]>oOd6i 4-܆̉,.c'v{gXD`j/5`I/qش8l97ĸdOW =c%^Mj@*1۩|{5]%Qsy{9x\ӏ~3sn2.nΘ\oHۇ'z !A6$OP9! vva@8Y\v vlxi[lelWeK&V 'm |5kWQ>u?EDQ#>lbs7 &tM<@+oO+g3O=r|6N_qq9/{),X\97K" ,\}h" ֡mi9 x(W!*@ABf Vᄍ?-#;fYXgc|p0pkiF =pݻt("W k:HS:Eys\Ɯ' _oI` } Bpq-ʲ^o%{b M/=X*m"l0|s Xa.ao?vq6'00:6TآnGDH3#k{?4M$Or}5=8Avu%σKs4+cTH#Jc:g{z9'˾9aua c6~n'DX;8D !|-wpC Ȓ@ذs< c2i=&/X!+r? h1 LP쁩A8ΗP.0 W|9 z: ڇ%>lZ8~K+ׯB,Kx"2*,tzr(O@CgN 3#)|uW{=Lje$`|Ɩ3?@~XDHt$݁Ntt%ǠKD'|_+ru9BEITZFO wyG{LVyJ# f Bhx4>t5rTWnI8,OxRك@1Ci\}jfƽg~ OSGaצ|Qx z\@Ӆ .ň_%/aއ IDAT"x**_˃J-h~{-Hb7Ω oo6*O>Lր'ʘa>CV/[|w5\u 2ò-_9o@Qf^W/ /[wHttxodޜ// s0mq)*]@BS/9`i7~:rؑP?d6[٦IŹsSnV29KK@Y&v_1pkX?S7p<#cpG 3n!'ڝQޜ9^19/nCEsxߥKjwQEBRHq[.0Hho3@˽ tb [K^sZۖKe/-(gr x8ru 1T}Is=_#E)? TI`h۶˃9m(@-2y.L W _/ ϻY>88t&9eB悺@U oRb`:s>t)4}F kϟq8ӂ76_]u$)eO&LύGc:!ʶTlApp=TXxq8О# \w37S<p]jKbظaMcW+K[>{gh, &iW6r&k%l2YM려 VNo{/B6B\ -x޶k<=#xb-EN>qc;Sv41iĀ҆G>Q2]aR b/'zq3V@. (-a):ZMbLͨMs ks  ŪFs\ω**uT9OI MO^6q@ٶU$0~%bX6 8rNf)TB̮9 yFyVZ2hXLmX=`wvx dO],K'8!qn&I:VJI(Pff k *EQ p,5]wcL4ʋKTRyA8Ϝnjry ݧQٜ l=A\pTdYԝݩ7Nbk2 Xb=n谗.qH.`u8@73ݐ/??߽^?3cˋq8`bJ =fva{s՛y(x7vP:zTwdl\[8 J죀Ҿ@W|p+y}徎q/?ψ_mi;P8%}| ASjw/L Թ{@ԝ]˜ 2`=Xb v^ Ϧ"^_I'Zόc=u{_> l u. KO0y2XW<9oHv&6֝3e# ru (qcp0* 0CILGc/Z;/0\+ԭV^my;h{ 漠Zu> xhΗT%.au'=חkqX7 v0[ai)pURbī>'\o&?(Wc4V޽< V4.{ 's}cW|#]$DB- |ݍ~xԆڽu:űuJl`!+\<;z6 !H5b`a & zD!%X?w}ca Ur>Kb`%~=eUYl5NC}Uv5 _&E;S ,_=d" g.( BGB`quLl4K;`Ww΄@,VݡԸ=NXq5t/(t0qiQ5;QGWLT΄Fmf,D @)-Yi-"H,X CbW}b >\2!o(eVk:Jvzj6_i"|jeikq{'0{+x\yɘ:.nUXϫSq$B'"Q 6;!0C+A9&T3se@Gz kGWZr yA1?n0b c@Bg >ob2V 82| @gXuaA+];ɸFq7g2h-M-_$X3gS8 ,^ݽ=BZs>hid<jW[Wժ *6oZ 2x>-F%I ~? VN80%B"0g K@9((ft&؝5ʸ_j$h#@lN ӯmF1b,Q^՟IP:v&ڍ V;6[ջCς )_AQmdP +|7kBlxia?fwHHPn꡾+)ϕHSib 34fIGX5Yk+)9Re5$cu,MV2wUKŀ&Č@+kV=5+`5)LuMR -DBZDؾ:ˊ,]Pg787W՛#p> [zW `fr 0aۍ6Հb\ΑK!6.?&\}՟*eQZNW`n|Ns?ab)%`֍@qmFჶ &ڰ ! }Hӫ|֯taT@@~zc(ggװ2G0kM[՟As1L.WmZ:ooZ~h ` Zd\l|`OK~$~PjR I}5>pJmünoۮe.18M۱VW^^s ~xa7Ryi{_:*m쩺mg4ZЗ {^T_ wX۱[_Pp  \%og46QrTiuBQ> ׁKtbPo֟%;eb5dҌ xBJuL3#`GbیSqY Sq ~rzçP-ۤm7'fŰ qQ l&zb`DLڀzu91C"COdJ$~ԶmiH힆͗w<\LFL1Bƪ ;8,{_^i{JPi6|2@)~7ܠ?H /et'"H+Ӏo5^\tZ< 6YqffϊV?<ۭ;W4r`{9lpkk8ReU ٬uu#Z0d sj#7o)M('xv/Ǔ06C˴B-I ;uR@qd RӀ^?5p$CQR-[_b`QL%J^o7vo.; ',D^@ :'3؍ݽ+/iq47Oal=+5q"Ĵ_m!f̎:sgfpz !nfd,n?;[ɻfsY13L#ؗ>$)1*p?(p#lKIP@ZƐUIĂ֭a_kaŰa?T,kX>>:%o]|lؗ\mJ: M?cH !#\8b8|#d6m0jmv #'bCZ4bw, :^mڰm,=v/ D."ЇC <>G-V8dimjj 1;W& @C,ީXܟ \3(WSPflC 1Y.\tܴ k>=^or {nOt&ΌZ:3d [P9i1 5'ȰM B|NtOےԝ6o&YF~5'by3"737WFgIDAT@C!Mtݎue >ND7_9y˲FxEM}fAU1)Q~'Cfap"Ǒf\4Vq8fd z?~ 0D!|&A.g[^y; ̴_;226F}]/3 ɭ#UN1 #Zoғ5F|ͲͶev 4Rb?#oƓ |,B 6mCkTW,ӂe߹5TxL_8֑WV*3SOXJڏ3~6Mcsn[߶u>hTnzԓ7P@`wA a++zfk^_*ԻV7^>tc;ˊ@߄_ן?9r]JW|9@K3MÄiߺm7zӁ)`f Y 7bo050=!w+v?ke ^ы%K3L?\+@(O p, l8ARv x -w\Qۆ~A8i/艼"{J*8Y>}FiDžT\əm}[umfO 6îG{o_haO6@sg 8 )uBwj˴dK? k)a:fj*fK~_?g0r~s%q,N?at]Ԕy~ǑOogKIFuI #B#fT Y3zȣݡ@*92כ]0kH\=c|!Hwk5?9%G̴OӺE 'w|P00Q9  0!09t+?~?4zk^썎-_I[πU r#w 3X V~9&E:wkƵؔpMLVag [VHx\%{zw48&JZ?,𛦅t*wMVD5g-[NgNd7*!$U :MHui?˗0GwlR/3KYiSk~\c&u0-38'+\,0gH4 r04|SKG؄״s.y6C!K]P F!Wȥl+I~Asc&H٨mMp맆V lR:7 fGοc(fg)L t)i9ܰJ5?aO}ɏZ[-}-W.%TA s޷`y[d]i 9.&TಳUp2_5~GO wm nz!cg~e8Ņ7K+o{[zwU4*̹VfKDWRGHG:jN"Vnr;oha#A?ˁ1_ ~cЄ#^X帇 hUr)[~ɜlaŠC` '() ʙ-Õ48az{5 lPKoiU,- \8T3CM_|/ttL}= ^ woFaӴ0>6%VPKK+.W.0y/BOR*nKi~vkMB j3 &OVNnhro09ZeLQZav_>KwIݍ?̬k/~d33&'>nwK/?{vN[|+Mg&,+m i/swa\?HZ8š׈ 1q #k_*mt՛ nm& ӏ xw75>lN%XV]RI-5YUDn0E9tC9MmM@^ٝC1ىX<ђPq`mT_ZVn@.\,o>Ma^5,po_vĉhWorj+Ui9`U$?/WϹ %YƤ̌D  yj nf0c,Je&'ԀoȄ|/7>\oj@/_?ʾqdƽ-Yik[>1]-@߸/ .-J:SO;d& )ispO0_ B׵DgHX~ŐSPgSX- 1q؆pBx`a=sfF&}رov ymxokRoH״Dg'k㢶gHo(vIR濒 8^} Zց o"o6&'3 J}w.|_"?p+8Oe'Sh{NGHZ%&F zX#_i}wV$쟴Կ8"qL(׿CedzD- _z9g+ mwr'8/`dfb ƬϡLٴL'8{*`Z9~;b$d Xjr{IHiY`L>-u#I$XGbxaF~?c8z+H ;3޼q]w.1RQOU 3F0|D "10qq!(ɸ!QYbԸ@] !C!FEd}o[ujfpϗN:oOTh\JDU)q't4\0V}i"1 K>+Do yp΀WG,H+dmԾj`z֖Ƥs=pcCGn?d}=xpLkN]0T*)i^ tW3Kg/Bϳ] ȫ|Hf1;=oo-cTq.`Ph?"@(ҾX|dLk/__D!(<|{@>C)L;|sۧӀƣ ԰[(,3aLlG c`Y B$X?~47p<{@ #9 v~A><|OY@ 36+,k < ndC'{`J@ L{}x)hm."u@ⓍAk׌܂̱gB"CLdc޼(~.5ؑW^STPF{jZ1拄9Tj0V%`EĀ׶6ݷLD%ϯ;z _j ~",',,]էwa ?w׏7ǻs_V0ʩ >xk`TZ~Z=G((((((((((J=7nIENDB`PNG  IHDRL>}IDATx^͜ p\y{jWJ$mY;mBlPI'LL^c<pB┐!@ڔ @4mCJICNyū6dju{NWgΜs+3fhG}sc9Gꗉs78tƭD;ίü9I"7]}-V~mג*ϕBqu {]u|A^aGbVnX !e-;z?ҔɬO$m5̈=8/LO9y؁xxkya(kN[7lkYxQ#RIc6l+Q-q}g}.dKs#{O<|^&ҍ%p|@3bƦz+z.iG1x4Ӥ1xq<$Gr<+>y{J VvwI= 5X5ir Op@O+RPC7 <Gd@/p8^}(^9(jKa-[ko|b z;[ DEP.ϩ)C0f65aq,"L' χ/TxV*$c:'_ݮ M-[ylÚ˺[F! \hg208` 1)Ll&\1xO7V9hwB{ܚUk$,J8I E./!JR1d갤؊3-%`_ #ٷxB,Cw!$u;W.D}"" ^p Ղ 3R@IiڨMlòW]tlaBt֕}]-4 RJR!uA5pj_ $AVRq u5j_"@ǃ8NP t4!? 4cs8 eY6):ڛW/]W3!=;HU8ۮSfa`uljoYݘ ¡sN` &,pe,d3iKDt؆M}Qk6#9^s Qb0@K$FA  )]a-yϾpX9twuͤkaRAr䖈H$PX4uXwF?p\}VU;>+<`V:}z|Up|6oGu|@)@@>bLn '=zpY2[x`C)g|ZLZ\t\'G'p~UT頻2 3L>\jSG{7{ÀUmݏ/XH8S86É(U*KBݥkP+t\$Bx{"+z0DN4}<GssI},V.$'L:L?;59=6}tT :Ĺ1Ob~)3;k8ijr͑q݋AƍᰘJ\erq1</j;8K/ȡI ˤ PfAPAXY)EX:&GO3@O?/$9?[CNg##NI% |VP(N%ſtj I~уsS QwTxjNmY(>f.O: ajŁ5;>rwaBo>w;ga&(ЂN iMt}\: H\0‚yK !^ᯬZ$ݶ(|&[DR$#\!BiXUvQ9ׁ2^;|ٽ>Vp:04KqGs8@$bC1mU*WGew"8a{/XmgRm1זtRC3D u%R,S$D.lb0eFԜk+ vlh~qտq󗿲~yJOƅ X))^YXB:$)k{umMbY2Ymö#0L4 $} JIŊ%/HD,둖o?[n2)-&w}_ 1z=[{sogfd꓈ٶsb0rUƘ>|Q{=o? 0= uXu%7les[[3O w8&R4 <g@&뒿hG];tS8p:W` IENDB`PNG  IHDRL>}IDATx^՜ \yǼvfvfm܀qظ$$M\)hDq&B@C0REiPBҴ%bD)8@cjcc8_gϽGg}5g}{9W}u͈co&.>|߃##K{|A[oףx(]΋ μȰX5Wz{ ˗oޙG:FʓUʕ̹s7%X@1 <0\Xb1,XV?8xShf1] {_lߺe0]4LX 8$`&3$0FI538hg=V~Hw9j `Z]"*u-leˊȷasS* $R s?hM N:gɐ +2pM ^)L]ħo^ўC2 A`s0۸)c T˶VX$$488$ ^ x7Q8uaE+v aQ>|ȍTtp`!4b A 21,0 \}n^~btW 4j6w{rg3dou/h-t2`U,U:hKUiʡq ݎ56ÙӇ]SX2Uas6ك$mvh$8xDi_'E|tkmݽ  ^w_8`d|jur4LÌK RDF$%6eZa0LӠfŢ!Z!l>*й BPDG>" Ӳjbvdc+̶Bf[z@4DzQk25rX?%a 4fPHMPygnԴ5(Q0iV<̵Ih ^%9=0a>L`]5X(=!5jy{CRC3-S>1"h*郅*&pU u=_+P K>zXڕ(D.FB*},(nP?jh6nٺ 3 -,qAt~u\TƆ ůӽ|0"Nh%̯iNՉ|!0YN- ꁷIYԉ^y3m|L]'h,:Y= 2N] r݊(4fp- b HB>FGqԫJZaB\knƙS E?ELN8;;|ԡWGT%UzOy.'pCKoQI!x K:uZz,/<0P:D0+ ){u} řcGp4UHR/DZvs6<Rц$FFwz]f{[nBUI :#I#ƆK8w8P5UgTreGKm\qpvpovpỎl 6:<6.!!'wA¹0 E2N?_=T .ipe۾OoETa乃'0 D<'uk:3!,' ;NcU&*- 5WXA=*zҐ211o.{Z~Ќ!I*Nt'raO "0BUy mPRZ'ˍ/ 6>6>ӳ u>U<fBӓ4"`A()s7~)+Ɇ5<:| LT'g=X<Ԩ/rkd_ާa { ̲?CGύ^?7 0"0!@33ʂ-eԎSȑ33ogN|ۼMB UP00±"x ]`.a"9$X(1 K`u7nP?7er|kTϔ 67M/M$¢>Xv耂D/AES%C8護0m(==]`I X㉸dk 8)B/yC⦁T:\GJFZ h\+5+5XʥΕ#ur_,o(J+`ȶn1r2uUc~JCR}FO{ݪE0|@!15s G6Eu}umS{g uTPu|L.MgP9K:za)]E!S /-[~ӕ|R[ ;5 su7,]9p}L.1mdsY: !PpCB""LBG00LXƇo9Ssf,RnO ݾqAJ6`WIO$>ƧÓP}|k7\M=}_~~m \fV#ݾck+mmɵhOq "}Mc1NθG2qb{jЮ9_y`ϓ KiEPzׇ>lنMY0P(v-dfnek??%+S ސ̴lʫ{gd7 y-IENDB`PNG  IHDRL>}wIDATx^͜k\U1wޏ}jZ[Fe)VHmT> .lS&\$媄P1"$r ]El鵉3l\xϝDl?q'QkABs0j1W:~ˏbP7f(nK ξİX ӲUrϕF&>/oΕzerp][c!2 s5ԫ]ٹ3OM뱽~iΘV<8v)ٗWP2<\Cµ9I!a38cI@3hչ\nfjS'h5ϟ9ƃ?_hնJ/i2[W'W^շlt p<ñb,˂ppn!H SBpsd#/$_ vI-koVQx&Rn `11,6mYT>포!)_F=;v/۬z]MՑիI{$8RNzr$ P@m0*"lk=!/oJ[^r!2 Wq*lCy#G#4p\\+umH ACU/~fgc\ 겔iuePet,zMZ3rPFp1Mh"_H'8x-?!SFVߤ^)L]WomCRi iHIJ2\Ȁ7W$o*gYP* 8 t ǎQa ?Dl +VnCҋb^3$T@P!d  48` jMn(MxٽϼaR 7'l/=@WK Na@yCu0!%s`YVU@+6|h~-@B+M.ZEs3V[h_,LQV*Kf N 70/y(<)ρ[Gh4BT֚W@R*sD Zճ/KZ 4/t[$Ȩ E 0lhq؉t>u fq@XK25ш(m~DPşuu:!0IPcd9L H 8Ž1!%]T/`L:P8% F*҈MrDU3:1 ^Ї_zԐ滿L$EG,?2%T 8RMd(E )A$O,FĘZ`K|%lȔHRJ4UNq|zqpnYَ 8$åH!&  U*>fW(LJٍF X7V≮#郯oulR7KA#X-nH0f\29WEB%Dxy 7!aY%-: DzZoVgP|{7 =]䒦tR:_aL]B#ufjw?t;`]֮rݶO@H$DD20Xy~;`:l`PfҎ`i,&,l6Q9{g) ̏rS=YZeGSI!s*' Qd%93M/nC\a$I!ev$@3PrԼ~= ˏ#hv rI٘L0XX+S5̩^?\4UNN 4$Dɇa"UN~Aj jocj{˹ٳ' RnzW 21̓fR ze3{ )xkerf|NlH $ecZZUzPTp_6ںc瀝=~E' 2̀2XD^dik2u~TuuG_?K|cS.!A&xi7d@Ԧ ~5,m/h]@SZet&*5CQgt؃T&!EtBaa1=M-#W䂉`La©cw?tR?\cLbJ2CYB 1Aj1`L6P'] d 8cOry|Eo6#EQ{(U+*EZi $ Nmzٕ]) 0 ~{}fو\1RB 3 AR_Ph8jr\@ߖ ʘzO7(l>8 )`{v=//GydX Иkb^G2 oIt\9B)-EwWqp'ط{LzHrh)F]p3dԉPhTx[;yX\a 7ڱG~+OdKx^0\*J%fs,X}OnZI"ӯcA+t3m:R)Ϋs-؞K8?N ,]Ёh ٣[m4TT9R< !QS&c֮$[U][a AEC6Isͭ|{aplOlO4KRjUb8g 77]_+_ϲ[C![5k3r"cN-Fmbap {G88<\TAv\uqA#[*alHf]C/}?~<9}Stq5SOytu<\o/TZCÀa矆i6VD}IDATx^͜kdu۷繳;3Y5kcA1AAEB,ΗD NdI,(%KN$G?;(2haʻ<ξfQ'='SvV}}[[Tsux?03*}y?]]p?|-Vc(",-|qA{GQ., S):Z;+̆i3pXv4r0 o9v'Vnx.T0C3A37擷/L{_H$2J8x/|â (9nɏN?aSxࠏRC*Ґ+I41F=27/-& Ǘx^H[jl#]7&ۜG\N!s.§ʁ`"Jh4czO~J/}k{; Vm.lۼoG=(Ch0x*_U ǔ^C+|.@aÆ6n /?p@$,8-Vo&Xb_ANHAP +LC^# ȫA*x:1 Mi!G"BЌ* .}>Ѩ_4e6`Yui JoT RH,#Jk0 }#YD`ˢ@z \|1h䡩Gsu%u sv>89YA>(J%Kb' @8eE G4"1|σքg&Kv9d58(,L{XdtȦ&\Nhi d2R9h"(]l4<> Zx0Ƀ^uϾ[ {WIn\'K#zPJ."1uQ$ ! 4#IkHCO}¼P;ZQvjW^a¦><<|VuĦ}-jB2Wr @hBCxoY`|[`j6I%6JSܤ@3$AR@S u|Lf#OӕJb>R$s` %ȼ)7dU;_p r=^AVaI벢 ([[xd/sg#8e1 ;L,iM`j`tI&.#&xS-`u=x KƷs8rV杔AbIJgN~ja5$|hM|LkF J~dlfX޸75h-;kc.jXk9-B)X{^!s/ ,v$D&Sg_vڱsN"ޘIJlD3ˀr!*XߨϴQWwt ɵ-ingξΩLT\0 9IRu%rX5BVY6w>nqx[ f[K傴()0dQ)h)!0  K ! /4l 0rvzXFas?9vzMyAթ.@$$|ikJ&1 Z*c4kl޲qK .f@7߶{s[GGJ V oE8Xd/)02hFҒ6k+5j`M7\ûrEv_RSMiJ @V%$)'†(}^Ll&~~汍_Woh 0AIu'RKuTrʩB7&B"(@eCuVy{3'~j׏apB^Q[EQI ٸ)i145J?)@kv`mV'Vpx{cśG&z PQ,*i#0|x"8:a7ujTkF[ S] Ll:M["UjTot~ ?ذfٍ|\!5$cC^6Ԉ,Anltw+,#L 69x|3u{?*nHbO(9˰@#2G7 Ԧv?df`NeRY܈Wyu.n7gSdxŚʵ"_-(堁XE"}lg{L56(bp|NYY>TMhްEnNGo%ęsv4EJUB(-O`VbHsY]{#{w>&ReZj r* C%0ʕ}uHSUl3&)MzrWhC-g&фY FiOWehě)@ܕ**lrqUX3QLG.y$ cc=ȇINAlג/G_/F^ؓ__n1rC7Rv^~a;$kQK9uY(z =k(V~[:R\Rw(\o*ucdS( dɜB-RVQzi@Sugg?xe5v73, 7>r7  %##,C$RZ5 ܄?T{`8oEmzV7F&On,h86 @ep6tv9u; o?{khҕz²J S7|_W2).?\'H$IENDB`PNG  IHDRL>}zIDATx͜kgy;9wחu;NpBL.&)(UPJPD/"ZVzAE-THZD@ДAhh i$ql=gfާg̫kecwޱ  O\F/儵T?rܷO7N-x"\XN8NxןR|J 24ucYA-|߽79l2krًHW<[x?r0\޿mj5*Zh$08Qvn']J^<~}'O)cZo*n`%XRݛf"uf4dS="O{JS'Q{ۅv™ń_xņ >/?#98ԹNV@ݰcb-|-7왮2 3P~a TP?@!MIe:K_lN[zo~kgŗ@+pnsІE Vt+moncz}0^~Uԏ/y>O-; nܺy4|`HG~x~I 9hÞknۗ5gtzs1 V5@Q9v!.FvD0)̄{޴띷lk߽I @Yl܆ j6a"X_4l!TtfR4-#Rs(u5#o(c&@ $hw x)sqNv5xA4RJΩwq̿?zCpaW]inkUO@4X981ä,σ4 $0Q=U'[Zz"NlaU.{fZnjRd/C$]a WEpA)*Hhe)D 2zCt;}ӅV~guw'}mKkbT #5.%W+ MmV 6Fy\5wzYkUK:2^RE{)a@fhA Fw^~`0 LaWMO6"|Q\(9 iH~+FhhTWA$O(0^|GlǑ~=|+U @Xh!,$l1;Gz=غ{Lr_U a!AQq^N;?떶h'uqX'ZwzF^vS;)S)/^w?~fok?uì$i6bUܢ>}%Y!?6o氺%`Ju6^*%bl`uhAmR.V-/*DQ7 EÝ(ǃ?::mS;C}׌cET A<>M1Ks;VÆFUS` %?r?;1'*"^y6hU={ qNB'}Y q}),oψU +;lXZ=kŭSvlj،lٶfh0XDc '욮a3P¤'B%|#f0J`.Mwh/-l O~ė~oW0~7_~ rgL2^u e=Z"=hA skG|2p+;UC6Ba29Z1vQVY`Ѫaz4 x%h\B<X^|hւy3IYΐE[cwɆT3Ubvx֫_RS~ ^ 6SڱjE1iQ h 廞mFpSq_~c{_C_F?sD#}=vJ :Q4AܼAMH{6xϛvOAq&>n~;MA-3v4<=ݾ:[6Dbc@|¨ eĬ΢kٳ]OOw|0w]34 T}˞wn o:eq[5oU%D}oIDATx^͜{]uƿ566 6&MSmRrZj*iZP\5$ TPAZby;wy{>:<ܻG9ca{3"B֗~\ ᡐngP5z[ڲ0Qg3U\/:>ŋϳ.8 (d->()>;LJ4cwWd/5a)?3ݹB3H'Li܄?<Ŭ[Euɞzqjf^~kGx\9{Qx?+8}b _}k{qײ|;rC3+g<:juhغWƦ?ynoٿ<S XhVwUw,Dz\I3 0Ltu1[v*ӕjntrrvmz_y#ttZSe u` ȥSuf A29͡q )+|*BzۺwKsݏ=K' NAkQXulCGr9 V 0 L F, @@ .СiXFdnmOa,Tx6 h4~J0$yWnڰ +`$|'!tg"L" x:4HpbKhg M88Nw:D+;EVBk׮\}霉TA7drIx^0@Lv5L`Xn(yC!GۋS03ZX9 j`qwiV-|XK2yr 85(xg`% ,e!_~c<_Za2Vd/ruϊR+ C N c`Uc y.yPdڹK7 q8qkaœ}Ϻ{;:LFv6xnz(885&Dh0 3p.43tuo[qݚCϼ)5 v^w߂q,8hE07tl[σ1g浨r\8)!l ‘.Bs iZ,t`p`.t=v tKYyq (Lڈqӄ<"IZ.]@[C4H%KI+9[:;Y范 q1 `"_ʈ D҈4H\%fbśBm}:C.0<26%L]3fde,=0ݘ/$B&ם,QX  xXJ !"q>~ɛǾ֤B9%S.T8..n5W 0t>&%_%9Q>1-W)s%H6U>Y f.O\ jΌ).$6B7^?i Ks/bc8 K_@v=pY8 ilSjؕW~n,hch c; w"6fc -m0U+}Y' GtUrurX5JU!Re:[$_IO:2FNl"1,T$", '/j;o3NÄhz>CE$GځԐy̏OŏᚶJzBܤODp<Z1AۮE"Jucg,H.=VOvVӕ+h1Xm ,F9ΏWL0ZjǕQ$Tώ 8f*Ƨ*G_y`#`-{Kr wɦxQ"ޞ#"8U/^5sKiV :hAO@ 5&W]`p$ ^!yALgGqhU5O[wΰK^X$`4@bOuF@mau; K@BR”عCxX<6/{ö[Y (E\% }eã'_j >tƛ& s*'E%" `1deo>+vi G_c\$! !,!?Bi)>aa86ة0]TęׇƧ&  AEa1@{+cC'rWw\zӇ=3zvEK@<7$*/dhmΒ[?MCR(h^ԻC˓W2htse=#w䅥s8~-lCmö_s{c ]  }0Ohf!#%x0-)fw 9rhk?GV*4MlA#kpBM@Z J*wGcE@-y:M@% ̑ o>}'7%$|R= f3d Ɏօp5u(%SIA|znoaX흷߻K+՘^;H$Ly(Kl 33ULG}Rײ'k9k0kD ::r{'eLDywzB;RruJ*<ׅ.ern B$]&YŜ1+:fT^xowHfg_umbt"|@$+Oω=7tkеƁbhFSaz/1JZe:W\{EWiW]fUJ+,W 0 7eBcajzp]3w6l[aq9S  CO=~yGs<WGTLDB;eA3=tn ha`eqYp0){'I. G1S7[XGEC XGL[ȋ&ht0 pABb T3TZSb##XAg>C(sEPRɻgD ޥjز VGwkTmߢ<|'$x}&Tsͥr hLC_i9,CEDHit0aY Lq<߆2,-f2fіA B8 G $3(bЂS ɸ|X>|g!j*f4еd C,Ρ-_1eh:@uo!"=8,HXIu!%œ/G>[@6lK^~SF-#sZd[}z[>Ӿ*iۼy[r: /f^@c2gi t.Ǯ'JC +pͿ [~jEoϥ7Wt6WH֍2 X(0Ra4 vb%t =?>-ayPC$epWn) ֞Q4K'RH HDx( `-pChA\[RFc7ۊkjQ_6}0?z=k}y_w{w-݂i \Yc1H /#-y@pnlfdS?Cȱȑ#!/uYv4pQ,K>gKhJ1HrHH 51ƃsuJ==iv߷go69^,tIENDB`PNG  IHDRL>}JIDATx^՜mTyܷyٝ]]ޱwŘ!mRJNHnM%J_6JTj+DڴdVUڔڲ0!!6\56eȾ}9O=s q#\\Fs{ϬKDp7wޏ,޶:m}4 pa sQ4E!~x+lof.,Ȍ}0e~{[yPZ0-I'~ti|fN>20-7Ձ7U_.y˙ $ACjDKW[ѻN[o@"1{W,p ̆,P^mD_6 P <pyq(HAkqQ$a_vRyܿ>u! (1nlXԶUym&+% r(z. ` D i57h-.'KϞ[]/N< J`ܦ00 . -[{p`rKj!m0yy@ u]֗ Xo4r0_xs|B)pZ$:z-w=pmrb_X`Fx PJAT:фN $4gq㌡~w%>=%,Lzv XqPv&t|c[qϪ֡J% U("%`I0#(Ǒ8 J{ &[ﵒ3W>7c0xtUwM Tժr וc*,AmRv[ӵX*}s98a3)\[3 lw9J]vJX.@B%`~Rw 2ĉ2 8+` GK>G8X0 }d30za&MmSlW*pJEA JT"Pd8Rs00TT˒(뺪|"ީ|4Zd.7U+[ܜJl;,80-ҕD&T qJѱ3W.i5Og;c/1Tm+%py X6<8b64A㨴Hfֆs8~UgF ёgJ[W5SVrH3H"t p%Aseq)3vS2p8gHV*n)r#In%1zߘp"$m9Gs}J5E=Ѹ)#9ݘZn>U+1/r l5VòЊjөҏ7K9+2O0 YXW9=Ù9qgIc !`B#;O={5 xTbeZb(XJY8h ± glWua/\w fRriX& D!1 O ԥĶ)VJ.?-̘ ||U2O<@]fẅ@JFIE 4cjQ$(6K2d3@d'$RX坋9c,:p8 \}G-2J'oQT\˅&t$R1`1oׯumԢB(r#^%(ťWͣ7eϹ? $Јc$DY-ߴV%e {RJR*H0b_HA_ED$Ȱ|RxhNz1=ïsH$wW~1(g~$IT R&g[!_\x @ d`ﯠCPVQaAK"F$T?&Ro^=R`jMdi -U? D"3Ex{aο} @+0n&Oc0!ĥXeK`:4'" Ah-!hVh#(&6 /DˇeLDm`$DuUϜ;]otإ$t'[5 CVG}؇64ݭe|xu/%&8l~pM['ABod0Ӓ殽1k>4Sͷg/70]:ݸ!ΰWn%Zpfq 찀%f g . N?~)5VBzcw,T]|B# JA 3KLF,lgiWwYj֯&{}9@(gJ:2BR'?p]&4EIbKHta8 ',)gmoc 955ql`QDZ0R$•PѮ[ZMIKX*:뇦FގZ_~s@ֱRJ )bE ,w`ʽ=.qX p.~ JHyWFWDaj IӕCTVr?z?4 v;~-A0}O9P.$p<"N_$m`ed\fQTuLD/﨔 Z4lͮS.*Zh,HJNTJ,=h],Ⱥޣ DG/Qp]S`t!U\ 5,bSv?tfٗKa,tx0Samv%^ܔ*"Uʮ0O`F>Q0|:WGwq#;qX_|Szf$!dqY{V-E*G;6,J=?=myIJn*1?A o  Jng[fB²g.m%~9:(BtZdRT:* 8V"za~1ܢ6XA>Ǡ$SFPLCA ؑ]|3{P{'3RS\Zl߷zx@a]/ RXz^0pS⬍0LS_^bu5t{dr0iS xXBc`I4H.Nd َN{\A0_ߨ7N[&H~eCsW I~E^8nm!ߺB^-ƭ DTY WA4,2*'F?wf~-˅eH;?XvtP; J%{>"\bў$ r8Hw> GRpmf -։J륫Op`厐,&EuVxW^;[zY|?kbwY,IENDB`PNG  IHDRL>}IDATx^՛YUko:CսzۘnC&1IP} "⋂A%*!-TlɤVzNUݺZaw8T8־ÏZ{3cu+<^u._!c-3CL`^ ߼ywċ" ( Țv4?aMPQZ(]/'va0|Tr. sO=LP5ʠi/s}`@c8 _ٻ; K#=DWn\iyԓ$@PUFyϦ$pfWMO8x jn<'wZraՠ6gΰxasȊ4G"2AQЀQNƌ'Gnޜ}?_ߛ~3@ۖ4w`рoɶ|g/^[7nn7H:H{B{;r&Pp;׸!=˧?36Gxv74lGuB[]֑lރ8Lb"" i@ ]Z>Y)Z)=foߎ 23Cbg@qΟlt>7$ b>``F-3=&q)J6>PC91gkzy|#'+yt&JHS t=#g `(0EHv,C10_ .r4OZ`%%tye>n!_o#z,] (:kc:PMJa:CVaG\UAT[Y[lypW[Ţ/#֮'tc}nŷzH@؅ٷ5D8q9{<>iAJv'e0iu_r nwJIiv?q}MVtyfX:_T;P)Ĕ1N ."R,OACH;٥3ߝ}n5ھWw$@:Aq?h;KmX[sN; LPAU1wā ֳV*%)>d8:t /B3 a١tp!j$Ec@UN;f:{ȄDf8y"p+(q`\&bf0#TlTWVCށrU51+o4\z)՛ilV&E՘U`hZ{UFCK"bKƹ,D[JfO-R9D ,wHmI,b1$(L6 hJ)i`I*8q n9Fwft0Xjdk(ƣx9i\A ,1#dR1ٛ}) +谦 !Ad)4FIVs@k2TnlílWJ S\ mIv_bQJ-à,`"`XL E̘ FQy2PV`q ` 0JU0E00kWF/4xmE AKc{"8bBaZxT2/E}nR)i5+j~&j "^hwl޸>`AW͚F<"b)ePbKAWky`V; bRj4FC0n]Z3#6*(fͫ{sn1š7@UMIMey5#TT-GywD`ʥd}`\]5CLjVjXj 0bxT1ڟkE`͂aհbϪYU!1Y8׺Y AK&gcej)VW*B*ܪ x4Š41j& Xt\Ak2\SjP@P"8 'k67+[7v73[ڂCoQ#9!UQ#4eVX+m ´j8(q$QXP  K<ʠGͶfv8 \z3[=NH,T% \P bS6VW^_pSHL2`P F xw"` E s '؂ ְV<}^P  \ ֜S`&),#HX wª}Ml+_s9緇AVߗ`>7&xL 1HѸD^  [x&A"0&{뛓\|`RƕAd쏕c SDS.P V=RA#-,Wn5oCIZݵ֣vJczA"`A1#!d[a`hXrW6+_!3I's; ssTLq.*0pAY i]6aMczf;&{WǗ_~ZR" 3CjF5[-`A(_G ;_R}l)b] 48A#gZ\|boz>._ETPRPr틃aH~0ɒ"͜ҥ P,L-"8Ksφyw8t}s`Kx ߀O}ogtΞSjUY=.qx'  i^8∭ "8kM֫_z_E&VXR_\X[tSSaz$` ~= Lq̳^IE%}/U5Gd)ia]~hk+^/}b3活 xbOqFG=@'nldޞ|N'9>[@IENDB`PNG  IHDRL>}IDATx^kdU}OsfeΙ녙2p\AM4&xFFz))G1$X`Ѡ"0\ƹsӷ{]3È%_tOu=Bkk$5M,'p!LiHDXlRIě/" a"s,U{MI[^G{ d* <Ð 4D"{OFӒ-:@-`Š_[u;Y_ߣO2a+'f̟㿕xȭ>-"Vf)'bo N,FA2Fo.t%S k~Hm#c ?#뾟A1]y' ܴ$utNVP~[N_S/Xӛە%K$keu$,XhϧC{w=r ^7|.85+OO+)f;IBfg&LV2.aӷ-i0wh)p! iBM>N~yH?NoG&Z,i'_Bk}ߒQbI2#8֗ 9|>Di9ΐtlDiXf'!%BTwt%u@eSB7_.c_?]̦iivB ?̍0#}DM%]UEYA:6F:ՕͮnG{;o{괄DbY[&`w|ɖM2rh@P[0Y6nYۓCiB$AvG+@1t ̄_{ߝ;=w#%+,^`;Խ!+ K <_<[0L\oD6q&j5rFBM\t s`7YcƷ jo+h?|N7wN (^ LBHخJ?@7,Cz0z#&l]GFnw~l~{$כ=HH^c$Zk,NXM!Mq`{#@d!ic׶@XכB9G!,ЂchHu%pN-Qc~/-"![<#@$3}-(t;"h@zŀŷB(2Ґ1XPxL) \, S 6 E?pJH4Ґܳ"`fPb:bJ2JY( hLXU=F-Lt)ΓfjcVG6f* !02KUv„dYb;}]zKe %%ؑ2ݽyIw1M啑͂vh;#U  .ڥ S D\{نM`#"*K*/bHgTQa1aܚMH'M$jИ?L pxaw16tlǠ%ڂ[{XiE╃hA* 03Q%ͳf]CP^ oh 5 }}LS9P))!E<0@W5 HSНw{ ^y&Gf84s M{)p(.U_41l]ׄvÇH0BʈU wih]IkuUWuP7ebڒɱ|v*SqXK*Xб RkXYaʓ "4fZcjtloOJO(4w2xӣ&Ez֮aQWʹHKHt?xOi/1{z|[h%!mz,Nr"gvx]Ȕ[λ R,lS:7+Ibv hiL~NVqh .uPU1v5]0 @VeZEdd]Zz=I[:+$z|̌&IȀBKWhi%Yu%M@k}0 4\{7\zsO:F'8kFȦ5=J=0?f^"`f [.IZ\ 1h 4ʞ%V:xGgi׹ۮPMpg5e륃S򹇨<袋x|+u8%CNLt=k. ӕ ])4MCI o=L/So(q=sq|汧}C f[=|aEbΝ\{رnIqPmύoBM̤Z4„<}gmVxǷ̷ޢWXp6Nб$&z]턱$aAJX"K#/Z /CHjg:P䯶$S(4Z Ae}ģ^t͕jk$oQh&i4@ D^P(-44Ѝ*ViЁat~~x!ܓU=BdzwIri`Rqh]VIH´˟ZA\V6ǎ 3~SW9G}Wo*u#94NЖ2x4T&PbB4JE(eZ]\dzl#,4{ZsvI$LimnKW\Ju=_ T ڵvP0?_c.n2X:p;2<@!jn"R[r( J^znO` lq1j"Cp G\CJZ.ZV( :J%WSZ(d|/N"ld6nx=gpm7u~@oT&&Ԓe{+1hB;Kƛnf> h9 YJ o=|ÃG;G~4]1R:L$`Ӧq Q  o|KNTsS }u6LOU(trN-s?}}e!v)_5A_ZE!)‰E 3ʔ&:fU4&]%. d酰f<ɲ&"(M+,WI9Ԝ2|/~li5KPrcXIVOהg)+B5ʘ!ZrPn$3cc8#JaIS:nf5@X6ah^&Zil:IT&rH 'HY/3q~ );S!TDdWE6qvFZP V2aJw*Kd/+JLMP 4'MLe*9FǙ8r$,9I+t,!Dd4Sca*Yc󅻺kk+7@zӱnAI\RB_c( Xnh K)Lo !'Oի/{wǒ2:-\YhyE cʤL85ai%Eah4Y%MYNҐoi-?$5g01+hXzsi:Ph%l. )-%*tk2:/PU>d&2 ؔʐirM:pmEk\G?ׅ᱿\V,at?ھE5/jtC#m2\A%V! ۦi @:Պ@Z"x2MDҡ:/dԠjP FO7~z^|j]fS@𱯿\+7 $oHm8LJT] RP K{E] K&&L:t?}X||@1P;o)| HΖTgh{Sf80S !ZӴJ#wlw7 )9;'#OϾԀ*P^h +3 eD@#Zʫ;8Ht6#q] ;opANK>-z9LR5[T,S\(3SSƶ[nzYDoj&n?)_;&͑ 6\8CD']m/M."M ۱1 RߴRl2&Xg^d߿7R\;'\8ݫIENDB`PNG  IHDRL>}gIDATx^ ]yno_z]nԒb $`3c{`IǓJUVdȌg0lN q1X,vj[.gN-ku@򌿪{,)%o4_T%_%v>Ȅ?" `dd$>PL;dWWTUK}Z%cv|N}^?7  v 0_"(N C *V~(:J 3. ;::dET1,&B/9/3TTPEu8tfTq L2K}]- ?|9Re LJ>VP-UưRrXClfct(ΰ8ϡQEU+˜KV\6ڀP5dڶ5-;@{LN鐚i0ɷGcqLa}BC>FBsR[7=e*ҀD@~6c K&F.z] q'#ŞzDNC nr>Mh׌S(`G6}URB9jQ:uJ4LWZzyTag+#`fc 0gA@70C j8V Rѽ3rI\b:ǯ_D]("yMF ;ZD^E5RO p!VB-FNJiBaEBTv.0 Mĥ.jV#cu yH`YαBY-qv4=y./ƞ~f3 d~,} '0 @U}4lKL1D8 JkW=N.4 >Ih!kU1e]- v}ǦniOaRUPV* n_k}v,Z5-vCwb"LJ&T2dj.#f8W: ;U5\p݋{0Go]5r3!maQ.ykQJyIr)F by\8\ah@T<Q`@JB e6HbCJ|?ddk{vtC&k!G)G85ԬM/]ɓ㋀;[쑿[U/jkg"Pɏ7ݼNgʣom[0>̻+%Za(s2O {J eNNqVڋ lyҿ[P&}tod{iK;1MAJ)HJ*ǀ@8 [tݣwdJo~rذa˫&E ).7rdIWǥj]L$)wJ)L94l [w:k[Γ3ӎ<ŝ&ziVު=a[e=K#?*?!fܿIz`uuqˇzq$Gj|t\ԹG^8nhRZ͹U )cIL_km/(`7_gi*Lxd*Cid0cW@`gUa3"u2T/ؙY=tm7UǙ>Ai1ѡjB 4|3Y Hu[ʢFw>0Ax2Un0\r'_&#$(:{ќk8savZJįhx5JGiUi`Dk/ vw`:nyyb:P&{@hmO};gJaHDs<L:|8M{H{ ܺtEh9 I5]{02VB.KlH|兖VDۻ40 8ҰA9B J\M?N?&c?QH>ځM] C+E& $A1U&P˪[zL;yqb1-@gO6MMMM /l*9wF\ŵˮ>+p.ksNur-LnI$`۶I!AtuvB!E2Vkl֎.&_ls ^z ȷ޷)5,zyw^2\>ҾhU|y@8-p3Lqbٴ} @ of6 |IJ 0u%M:eǙRE/MӠZy|s|k׎}}ۿ{Ӧ} i#N?R!`!''7 S464Aŭ#)uJJ2jirAl*tf6cGwT rŕ {s0{_czz۵UsxG& $J;ȵ.\:00+DͰj5_7[qLf2S>޵\.xdz uK_M7RM`&DV.CͭLNp=A؁NR,M)DP,\= ӏ&-k{_U޴0?32c1HO&k1OU[1kt`P`>^u\.LiMlˠHp j?S߷nyhv֡͛h_GGČ+2vx,4.*]r9LƉS8Y̶HC  T;2|. \R+M ^Ƥ#}Zf@/nZ| )١M,MAwM߿ L%2J랾YV-ŌJ!aV-3thQbYVDx^@T` (;[E$lcU<YO!P}}讫/ ˺@kڥѐD2D*H i :ې-ᑃJ22N?;tly;kuᗠ&9.bEο{Bᩡtɳ=^L ׺0{IJ8klƾ&<,S"CGJpvK4PUߟXIqAM\ENYеUyK02)*D0h2:PT@g,01Q&hXXZ\ѭZ4WAH2QrA4l呵vX@ W#υ`V_g?s5sWG7"_Ò2dpڬ8>2}tԽi @ +{Y \9|݄V7Rir›.7b7\ϓ?}AZFh7W\Y[y̏0LaJnbueRمwK ifFkh;4Z.6s`hGIm +a[:Wy^HD< >~$.UЕw?Ź+me<5oA;Ç{^wTbGv;H;3 Nj} La ڇ:5}R,I*U_TP&5,|F03}㵍40>!Lk+dpfI췷 i aaCaܳ >jgʝM࿈輔/{#꘦ Oے.hI+)-V̶7xՋbBcהDU'YVD;ʛ7?×}HjdZ|lQˆaA"D]}IDATx^śwTTvjC/#  "* bMU#)*X{\51Dg=3pYf0>ɋt|#"_ N@zеv1\5o~T{Th׺Taڒx1}c1C zmF7a'xaLGG uaQ$C ɔX"x?8_ܴQ|!6HSBy*$IT~QƈIJ fsdW(P}pǙCߩX34&j9-+QZ1_=/=ܞ{kY[=Ԍa2(M pJRaN Aw&,!MNFEi 2jwhy 0Y$v+<E}drn5&TxWX`P1$Zg 9=SD 05I12hF)b|05-3}(qk7rv<;rA%,E d1 -VEzJ &a 0i@(HXIr ]!L]|_֞xd~y'魄e|T董pw D1> ֓](%&Gq1P0Wh4CzqQ%WhF0wbdSdk lh%`,MfEAH?Ba:š3f םCᏛ;I;# MXZ@/b}PQb-e";_7Y\3|&]:,u%L #QoFEAވFoaZ@t5H|&aEJ@[.T6L+֝ bceej ˩v C 3U$0PJbCxRa\]Q+HD$bt űv##)OaLSb5I¾,>0?o0Tdb֏Ib2dϠ< 8&ޅ6gi2Q+ rԤ(307D!eEWh; ; twsa:; sv!LZEpւh%$cT7 \ ε2 LN,xaanl e!lPFind rl{v`NX.m] W[1$pAR&ʞHnaM emJSJ5Ni3I|ȜկYꆺ:0π?_؊ naHaĴΤPZUL'68(r i]f^'QкTɞX V}J8a)lLPgnQ`i,8a Z \)UCS Kni;884I2no߬cڶUAp?i &֙:=z)$􆱰kQ6cA^m%P{{1ۛ,2jaX`)8 V}"_=]'q=Lypt)4U`G&іVNa-FI ;2=I;n ˇ$_ҟ- ~( 1M4Eq&IHU an\$ IV= ~%Ʊo08_)ڑrq GS5f8al*bivCߣ(]# CoIh L󛟒 tH؝*8i4B.( |E,(貄 ;vIe('"*iHas0 e̛WvTatWLإmS=KvT?VD$uDEz[XqĐ}t'frFõO*X=秶IzEK6>dN&υҰ}`%ݖcw,o@)`ēk) OV7Vq}7-xl%T/ +°~ɸ2ӣ_ZTp)˃#D#K4֐„Tp}.+ pc<Nʇosbdw%YvR}:~MM4t  vƫcp  QH_wĖGzZMPߜHi E,rS)JhF*QнnGv #0lIʊxF Hc0vX(,fnA*z!bIEg %6޹ ẃggk?ڀʂ^v6\2V"v1Y$mX\spUnFAH"EJ<lKSXLĄqH̦9T4䈚1I_TOV 6յV= rP^a?))$v!nc-I GB(Zf%۴BHo71mRx.?(!I~n}6oWlL-5((AXGL7pN+QA_8TaD2ӅIXȟNֈ4O'=xñ%pi4B' 7vW$ EFx#=>J˦:"I]@^] ӵF"?ݩx<y(t\t6\^ ?_ v,ȃ]5#Jxxh`]˵lٺTs7i ]v=;Q0%ұ3Kj1KiXqm‘y14jy ]ODң3lRzfS9bX-K˝ +Tjkcc\j4g"^R ;x~ #i|&ߎy#TH`-R' ȝ'7LvVC0(֔@3j5bIp"-hdK-!KrO x(b߃Qu0SLPIR憸7 Xzynat i.PBȸs-fKҼ?%O($DROJaX&ɳ`K׬)FyL0)"'֍L΋]Grli6TW%$· )-5"a9ޚg 3G”OeZ\dW5ppHP YQsHPw"IVc6ݻ;eۃڒw?/R[-0#7KcE k\ti}e' ӖnDwǵJ}E'ӊ· #TXR&lMHú\[,2'{1Ʈj6տQQIJfV~rrrڲN֑E|s|VqMZ< N}8~> wuuQ̺Ȑ)O1mǝ şCzaڲr)>RJ.0Q"CE\;d~`Dĩa)'4ӜuSRɢ#t*'@ZG L Iխ͸ɟ):F¹WӍKiGm # _qTM}815ۇ;GNBJ?Z4+闅}R_UGWL!Ä8Lƕ3A%K ~ y|T k@H3Vx,kX1!1 #t0[|vr擣JYa?vӅ==^#Y8UAQ|h#0JG>g*3ɇ㸙{ K,5/ĚSj=Ҡ7 32RkY1,-J9Fzla's珈_ev/N6čƄɐs*xh&\UŤc6{t;,,; ZFLM_k&ebSmEFzxFQIENDB`PNG  IHDRL>}IDATx^it\gyY%Kd[8’M ;I %H99J^ BeiBpNHJP1mŊeYHfϹcmF9Ogy;88qlk6CSՅj;Gl};4GllBTiA`[1{>?9J?}T;8UqN7($_(!R @^91_8sVs=7lKw TCMJ>m)_^ `(E&$::8@QD acõ,ض {Ͽkbv?<wdz'E @5:rؾuC{]9T& 4ƀcm0ld̢^r2^lK^<' ~VIkB(]_07bH$ؽsff= q|_UUW!21:ײ,*eGe~r|*:ZNKQh0M 8TM C-yf2"ŹT 3(g|r5(0*" zB{%c p|tX'84TS@tl#P$? ܃z4MZqPT(d jǩū3qv$+gGWn/F!e/SdQ,!I9*u8d JT-W1Re6 +hRړP>Up\?FD1 I+>TC4kGVOҙ "~.(VP ,"Zr#A0t1誄*#)` Z`pm +8WPu5E@S 1]&v7 8E* 4UL8qgǂ^X>h&4 A0ԝFP)T \%`K]:(-)1TM 5EPa{ݲFm407pa~:g[zǡi`v| (P B@j6Hl pqT }@a5Ε1=Wt6NפMkk->bޜ {o6ź v+G#=&Zck45_#2H@&~*͔gô_<* H8oDNhwFۘTUj<5ؚ'$tlTرZCtg=G>C c&"^UEX U`PŦ\Q<(g<񢝛iVBICCEMUkXoe][.m4ϼmWX_XQ|/UHEUஓUI@OJ$X3,G`Ẃ'hlc:Sqi7ɚUUJ7K|M#k!DjM$Ql,9 'CSd:o\Ó斚Zϧ!!I\G!  £c<%Ai4USё{ -q#P t&i)HM#p@\%d-鄎a=˦5CYcH]Г7ӥ"@\GlS55EKudSz>X`PT,l;Wt@D:bj|n{S,^sXoqzЛ' |s Ep,5r㘯eOi;Л(Psn66Tɵcj,iRPy~mՐ+lZ[Mد w=r> zt8yJE ]I݇|- ;q*41] j]}]h;0]Vb(dimpU8Q "喓0LX4+Sn( ?o G?ƕ1aɯ}nބ& K*:|SV=Td{ |ә &jKN8YSaࡒTBt;LQdZ*Ls Ձ隌D\C(@!)PoNv[cBC.*75቙ҩ883uOO8Q9oyA'߰[%ٞ?Y.ҶתQZD_*VwVos5Kj͢r->9Kezȫׯ!|{tTejsH҂M&[LŌIcHOLq]H[tikl]rXVX}d:_eaW@OMW)WMYїʑ #@w0 \m٪w-%h`-gX85n2na6Ƌ*Qyl=hJuv`VNfbpF5hbZWJl `Տ--*Zk#ۊ֐Sӥos\CU8E!\2'( !y< K󑉹naqnąE -'eugܲCs|LJWX]-,u\s0Id˦k! vzڱ}Hf,k`V[LCeurڮbsJw13RZÙ(-<7Zj)׬ kT킦HK}@Xpl~ b=[Q[<dmvF~/'S7 t촤 CIu'pp0îzkƍ/+'}~<03JKs߁g>{3'JM wڞĻ5w{/r]O}_ǯ^~  \KCFybj$~w9msκ3Oi7> R93W.R_M]T"׽0gTu֜{J)lwӟw-Q\@Ow}C4=D^C=01 [em40F~$ c 2耞^v6clMHt3^"M.mӪu;=};o~K/~MW^*^w:x]ױpor1OQ$f2݃x6ąoA%V6[س{k~wfH3;䁻xxk/;.\C0qn;x2HU[ \s;;<䛰̺Gfgu6>k?ڀvuobHF]{icT%  5kU4=8{^"vCSËxfHCM) OII'B~\ĝr4sw~]a_r|+p-o͢Q5~iwwhG>lG T1CG<#N X:;;5H`·?/x٫_~JlœS84Q,Fw؈TlaȖ}7^ C][^p;vl03[ X}VYX4kg,Vqtb<>3lFߖё5e!hLi dR (l9躑/{ g@e?}[@?]}}+^m[48zrIL+TZ=!Hj40D ݌3#[@)Tc`/hZ 0 }##Ws4 ^ ٌA(udƎ>XLr|;^ 1)9:m.)< ;15@^VIFBmH:49b}ĦIENDB`PNG  IHDRL>}AIDATx^͜k\ygΙh++˒Jlnm6@.m1$J!kB!'闐%&q 8%ֱ؎^/ٹ^Μsh;=hg}yߑ:ɲ,iض jຮw-tN :t(P}Qjuu.jyy2X~ZHT=%P8b~6u >A{&9Jr ŁǹǢxC ,,|?`]BjђcamD"8kc*N0CCkMM~9k kazmp0YLH{qvppL|avV^$ZzfEbjZ~XF\_& K )S(m+)I@G`fנtԓt\.7;33!If1AMi?z|[ҩ}kn;-~0e*Hr6 YLNMCfh 2F,pۿ .֋z֭[ViyF̚ekW1&~LW-;v s?d2 >^^`],+( A ,'Mm h[V@ Soa;tQPaXn\RsSj ta1 i0 ; 8/kXr/Ae~-N= L$g'Sԟ{l3‹mByKҥ KuC0f_w<#8~#gPCRnŽ|1mʉJ#y s70ucxi]a8RNNFOJmsxFVE\R˥Tu ׋f|=2/<@ ,'e;c;A)l=* }yR3CO`!_ͺK>腑7:,u}a,<|Ս71~`;u49LGX9瑿+ebaqiPZ@2 :~xv '>gHI o 4JE]$Q;_]GˈZu{Q/7R6_sLXzSI!}o__֗pyd]CqoϿ,5$/F'Yv0 EXBs<‰8q/)iYSg{%ؖ)֙X5n3i@zxF; e8~13riZ Mضp }> EІ ̄%Q7=ObvlP e%XjȂfZ,YlO֗r.\y.ƈɑ'\wgW7\tƓp~Tꘒ~ vLgGzDnaaA u/̻c`hu^ɂ7Ueэj.Iѣa݊.;tM˂\t2Wr\ڼtR)IHjN͇臬d\,R}ֺvp UtrWxUԐK`,_Ǎ0ڝz b'٪I, %^`tUaXU>YweyorrJzcwHF ,С/i ;|+ fpIR]%k~hd`\c b-'8!)mTzjN, ]%Z>wY X-Kƾ ,VvH&*!hYBJ5W g)%šm[zI+WH &,(Ɔ ҢO+h1TzQuwy]{N)+,wϱ8BK =)=; \9y?e4bz{l16MhSߋ aܪaYOPh!EZèT!7+wWKX3 m.[8=[8DDwr-~ /蜪WXԟa?oѣRܸqCgy|V,.GX1L{r닗$K',Nw⣄sڞ! " SY^r2{e n3. :^.&@;="[֛°X|5L>Dn#3tELKqJ+ ѽY[߯0_f` 20r*0!8Inआd 7+0eIqSIk17Q_( Zt{Avx P^[Ԫxc|q{}+JPbTu=[; .ZiQ5^CNeYKS#t⹃jzo wo)2EPHܴ~>?\dZ34g:ZtݯۡW5D T:nx*nrKV ϭ>*L wKՍŭիh`l|X_zo]5[ʌb2С02JMcۑ"*c8=4x(A*v-[jU"7X^`iy}IDATx^͜ xTUOTUR !A# "Qձi[zX\qn?ϡmdpťe:Y-YX*.TyݗW|y=~=PF2Bk?!59>?8]nH4ŕKHAί16>ǗCɖ)Nz},tv@0*9D# t_LrY5ˇC>ܵ %ס7^X S e.cͯE{ )E׷,No3h5S`p>Oێt#0H$# -Xav8p # ]q[_{eر,1SM%3ǁ`XF 愛o 6c󩆭_}0xRXF Mv!JU I}յNďqwef$RSx0NZJ%AT 0'.u@{Wv9۶ȡwmI΂tHOKxsc5$0H~< U'ZgU޾I''z)@C5F %Vv/,{(23Rl2biZ&@d`P)N8 jX+*shAxqhtsQ&E4ݔ_vIQqA~$VG-C(Imppuؠx3}ƦȔ?>tH!"u kTJ0Ǜ࢒(14O.E5/<ƈETUn%cNi%ܬTi5 ¢k`IQp2ٷWX2)%*4I(+0lHxo~|cVf *4YaItZI/+)(U MuTJ $ys=p2 C@ @3[i4꡸ .-oϸyL6`Rui3sJR  !{9JraSi ].`v ǧ-&am Y /'k䩳Qb^nBҳ WZ/*-*ƟGuNn]9[`(N, TIk4 yA,VS=1l˩0^geeMMN@ٓ@HdL&)d̹Q&_J z?g{iR,OyЫT@u6.g{AP!VUӦBUU,Xz:`Ν }6;ĀW.k$Ou-y [\Mz:Ls //,^JJJ>KGpkߞ@ 1/*ucp` Xz逎VP]:XH4T,ҭv;yE?A|C@5Kyg,2 s]Vhljr2e~XHno*++1Da-9s`ĉDI q%F!Ċ#k !z 8{BPʕ+b&ckG YmQUXHpNL.KXC*fΜ ]wx ZQa)lƏP"T+ y3 ,aPYb%tCS]=GĚMaq X\JQa˷;fw-^HIIr1P6K\.j速lX,Brɟ^F^2*jvPanRa!A\ KE p\Zc+RHbD9v8,BfQj;ow$T aFpC.,SX%|?sm!qb# :Mow{x#Bl 2h'NOo-O" !?h \ahK#lcW_u8=VMAeE,ΰyK|͕i-:P}~GjIW,`ػmqSXCZJ[RElAaq^Gy ,}]78u?$Z #`3MGl'm1Fc>?1oT"2BLCt&-7A\=ij֞ضemgO6a9 KJ$xm=) ]QT XNPkR Ӧ'D64jJkZg1vtF{}s_!9D<"u%%(3eo5ct^T ě(B^HNpU!g\2 pXdzz+K ꀆv9PWc'o6IC+ Eh&}`D s᫷~n})> k Yn"h]]]th4 ')l잏ыaug[>(⠆/Ryȕ~91;NL-VR+n֎He`iB\ՉC'Zv~qXh 8%H*Ds^{dzXF?.%7>X7cܨ^ζr=}KTU"Wֿ!,K) x'`^4%B;6L.яM6*XفGns]}6:#Ʀ6OñܾMyfFhA?Z]gwqq8 gԼG@!p= ]}mCU  @^8rBA'aX_`q(wlFg0uP[?* anVRI _ B~/޷aò(gBlۯ3ԿIv^~ރcgPjӯF$AqAKLa;:]Ɛ![I@Tq⸚&Ux zkrS&@]n^!(#:}uoYM L|"~Q]k|NrOih6BfZ2222K1>Ja9>aHv;? L@ԥ6 1~,(+#5ӁvFNf*LyLuK'Oɻd]u0yYƺ x8;t %~>"Wydh_QR3gH? #kҋn+).:uR)1 LKkJ[}*a Dh@) l_ #lTDpXiI\acL*Os$YMOL ЅNQ&&N@E-O`QF Y47Gh~_~CŮ/y&:]ѱx{IENDB`PNG  IHDRL>}IDATxݜt1I42ܦx̽|\fPfnÜBѧv6.{vգ7"xcIP]7ܧU_sa*V&IZ˴Q]p'X$|/ E퇎kH g=ovuuIwN"fCZ)@*R֚b[V.z/f󥥋/,{O P$i9m)xҙ-ı Drn yt9vT)*^_Ǘo۾p˳@9Ώ0r<Ƚ:j'1NRRmx#²!! a4ˣ lFd,^.P.mbt GNz;Tg[y2bgڰ@eR4Un@i/oPف#s-ޮnw )U @l{sDe (i >S =d?֧tے_WpA?p̶uou4Np& !X 9 LJATpX.p,uɍ5_;V]#,"b;iz۷?T}jL'qGVƫ0QF8KJz WGOꑉ _ɫT-mj zڿפt!c1̼ 5=VO4Bi}4Iif(ܫ~}Ʒ|jV30ƀ7>6G0,;"bׁ+Im RP\9FB# P]Ztet'Ώ-4{`@߽岦v*Lð%AzP\ > Pzݯ $qt 9~_<{n UKXcY+"C5~J(@ D">?`۽6U`ioSZok:܄;e_!K"] XqD:O_4샣 rrdwv~Ja%#<E?{^^|(!bnu#G(߀p Xs/~&;6L3ye%eA.ͩngvGfhAN):@H=3.>v}{gѸpd2 [|m3+#VKP7yI!?\P+`jG.ʸ}"6{.*_<'c`'w߰ %t `q+yyѻ9ؙ֘$3c5M ~5q-p֯?7; dՃ 8Ay+G{Ľx[zyδQG}FںѾB!gsk]- BQzlJ24vq'# m=X v"F_g>I>#H6??ܳ fsˢqKHM@8CȒMwg}ϟ~qciLF9Zp2gʘڨ];''aviV+*VUY4 :,g凌FH@2Nɜ9 5 ݜgi)nHW"ڶ#Nֲul?h5:ۏ7{x… jv oF@r~ f p=sbY¶t24||I=\lܹsb|Q;3nV-Z 7w N?M)5ک@KA]'_zwhN8>H:"]&sǪӧscD_Ǡvb5Eh@Ch6Bm~F؄ljrȖF:1[ㄅ) m!!K}T#+"h왴iTɗm &'~T +3+sDk1do04sЀt|cR21ړEvCʙ)0Am~}[-nadT9%̖ۗ3I9 !@о}|=^3ֵNXUDx7QCXH:#־D֏HȜ_4"H# RB}i %ۃ[1W6!UȩzBUҶ7UW_ ?D<Te7gwBjV !+ *IDDQ sȐ8p?v_"zq4a JiMb@KPWGUZZ!^5V 0(IF&1¢Ȩ`Jk.=CZ3|et*FKJgcH 8_:ޞ@i ?F_ h'$'4a\jx* 3ң;/O)6OsS0\(?ǚܚpj&`ܻ\g&/ʣogeOwi\O 4wI&ϯX{ Ǒ;uo0kMݽ#hI',v,+ivp\@cZBe)$MXh4xYx/䟋n«aXw}_'f'8 8%<_i!(c"Ӵة+͓ oW<6<`@y3#f'S&%YvqlۘC3:IDP@pX@XR/i(hE0%el~ @)AyZf{Fx7j`T7\O8+*p$ku-8P%YbB2q,OhJZ2}S!h<;J{? 4SAVx^(aF3ҵnyѮz=W9\8) ^XFI!U ך4iJT}/UJQ:?uݵ]c]%x#=ן}j\],"ߑڭGIaڶ:$m`d[u8;YB >=+II :Q!{u;w]ЖRhOK~G/m3G5JL]K%leuOu IINu.ˉ(Db[GV~pߩRaY(NH<~e޺6ΞMcSiѻv>%=qLˌH)_\{It6KV+iJc02F5hë a`c5*\@z?CcoE)XI&ߑc(ǮV&}Aȡׯg]}uq*/FYF"u>S$jstdTpV^u7J!`c)ZnӿhRz6 9q[[`f$ jrYJL;iԦM')A~9M#-ZU`a|o뿹x@oXǢ$eYep_YLI]4v^z=^AndLKZ}єd i CC[ 7 a CwDvXa`c0kzQmΞ9ŝH^C'/Iz+7(p#;VE㼱d@Ek6z;pQ`OHԝvHsw&% ~r&%ebJTic\-l !ƢwrK!84@!Uel3.W5X ࢉ"6 C 󣐌ucB&{IENDB`PNG  IHDRL>}sIDATx^՛ 0]iO' dEb|$>Z">E"DEhKm軬V2(ehcX2QJVd2ƲVR>'Nw+79U"^ wHPI[HIz F,^;@Uʎ!`mQWξ?PtNEAVLʊ i{ukJQü<ss2so%YK`V!h3]*ٙz30Zj9Zk4 /F@_u̾HS@U(u.Ի4<&Cog |? P:"ͬӣ܇ᐓ o+ T &Ex۸_:}Ȃ@M 6THav| s~{yP~.}te2  "MP|l)`5GA^JdzcۏwKio%` L="w桢y8t0ME0\#;./req"S\C,J/7"4rjJR!7}NqhP.:0:_ޏCP?:n[x?| GZalsgBpwx< +dqh?U`d]$^XfI៳N1?7ե`gyPO+ e5tT@-?!s'?$7e_=fAQfjuVm/+/](YG#ԖI !RէFwFI2xׯ?OjoHL P#Inض sH2urG#^{J`nfV;NBE~:ȊaNVGc 3+˰K PV_X^Y7*mP[t7 ̍V;L BcE.kq稟Q[J&˘d%̲>Ekvu@4] 3 Lt,qU2ii:{:zćl7J[nBiQ231?MA؈r')ff=Z가Me}y~ Fk`3 ZB`9b,eKu]q|f@]Y4Te Ҡ aspr^!'x 7̀f4Jau,e:*0Sc m'a^ /,u|LW;x")j0f"|UÎ`Eސ.`*%@|C"+K qzRL|/e#{G5w4V37LB=Bǁ!OHA2~<`[$+XCdU1p-y I WX\ZYYRQy#aÆa7|>ݿ3R4AkŶc2`uk/`0,X5.ʎJ)ZPڰby}(l'O٬ `B*],tCYn44U6+$T,@ =\L3'w|r=pw 5XdL, K\y[늡Rr}+kn_u=D_k+_ _J|1 k#rN XQ!{Xh26Tb\e`acV6)ʼnG!E6p/ (jLٱA5=MmRN=)͉("a&| fgXR xsV%)LU;dyi2HXFweY/W\^_W;i`[XPnGQ%MBa^%n[@Vk+.gUe !Ue2S`7~]p1*<]R PP".8X/!?]l*cMsce1t52-E. ~^tl&_,Yg4[Z}-e_d6SRE%jW5Vlf$sytGt#vaMVz4aw=i˭*0Wg`jj >} 334覓 خTQ-<`1 upܤK~ZYRo:^>BuҦյ j9n gX0g $~Bq%D񊧲HmR0BfM).v7:_q\z0-0[r $p멓f9h-MG080qNcxd-/pXpn.JCz)6޻_ޏގ6v&ZWUfP: &1y`Myy*ZZzPo3Yɽ{7 @c#S2 @c3#΅9<`4!VR;TJ9,E0?c3|ފ2@Vى yq IHq🣂ϻ| ݭ0PPdoؾw5*4 }`0 ]E]- -*U jGB>ݎ֖FEj_ _/6QQi Әc2LwLfx LcP_]i1P^Z`#3>aZJ5a: <\Y31[6H!+vP ߎ\qeUQVYZ$ԕ!B sT:,1aU&3SXބ.)&&&X ; EIP}ܧ"YMqO'L%o)[Lx&wL(^#.~t@Y?UIL߬nhl_uP5hm  ^wg;FCvJ4t Q|jCͷ-Ƞ[7g+`>7lx=: vؘ@[H CPEA`,aODX$)"0)*pȺTœPg4`# TPLaH$>5k&e:ZX4TWBv22x<>^Sl}䶽nu0[,ERQOa4_ɧϻWkY#>վj{5PZ uR(Ȋ ёaLd lAkDHC}LINȩzrImV,N$e&&9x8x Š{"pS 7*nY /d@+;*U*IAki_sU Mqv -b0b٥Z=Q9lmh&cmCe&rdLz:da4E%[g.^ dWi c- 'K`L`mDtݰH~%|Ɇ(ϏfqWS[rp225o|7\RSX!剣"rŠnJ Y"(M?qn#3u Pܣz*D'%:dypɭxȦ#([FY.P6ޢ)|\l/1s.(ۗM eH,vz-ƎMn%EFwn?Ae*?P⭜(-w|eMq,/HuAO.;T:>2L  W6п`6h}["0atl/tuF-;\aU:f=M~nY.[9æ'7.n}]] %IkLcg&&#ץ̀R &(Oo^NHaʌIjٌ{"*X?>au։nI`gwwq1wP_al݈n;<|- =7ՂwnWҰug};A"ق꾟9+Q2Ux6FR'xPfDžNoj<^‡ao,Ɏߓ Ȓ 1Y)*#c7lT/ң5?FY)m8;42I`tQID{T)k/05ޛB3u!A:E3bt & jm89,(k줠q`s3+wOqgEOUA徦fJw˅Oj, $؝YsL)8{m\]PaPV?;̅-F՛BR $B1Av҆RAl#B Qe8'1Wʟ#+¦OB%jYΐ4Nd 2 <޷fWǭ l}Q)Նi]Lq݇%X J쵂nK3458%Qɰ-F2\P=\vܽ\Hh; i>1Nf ~wԞQ+$ %xmD q=h}A`.7ADvSAQf ªW:enG: n7ٳBd gl,!> M2qH)e8E:܍p_p}A@5CHЦš u5C\0UN%@`1WK`@D ! 0u0^ptXphEg̭#%Tr) }!hM$s)UJMւ5ڂ. Ӆ5:U q :l|F/T9jvXt7B`.'pߖˠ6.Me=)uH Lru6WNIp^}MIDATx| 0 "Л ؎tjD'IM~ʸGqp4TXPA:zZ jΔĬP%X5 gcrO:Y9)X<=,3 OدbPiAQD#wR;Mk<S}\+bqr10nZmz~g"=+ʄ6>Wj?S(W-uv,YLل 8Opj e!?z:ڋS~g* RʏKsSV*.K2(e` H /R1`1w\(zҨś{QxyrկBhng0sR7҇"L)l qbcY\~0\Mr? KNOtꧪۙJnr?\݆W_,<8 5hʜ}Iؓ셝i^/u>'!}Bz5-7M.`)WPMBY,YR EuI^YxW+xv|-GNώ7QkhL@qj&`w#oE$/qL3#}J ~wy&$=c{`)5yρɋxzs2pm~j!Qhycw,cqalC݋HK=6] )S%$;28|Otse;<\!zs0o`XE:ETK<[#KELÞ8gwEx|c\)#'kEz.RQ6פ:{lTXR|YaSkߞ } xyF˅\D0\*#\e%B'b#경д=-Kpj%xyV⛖"]({OD6Ch4_hwOΗDỦ:P||sW02<3)sFcG=nĩ@\Ճ+p~5aX5n:;"tZԲ{1o@)`ͺ-QOOe{p2GV\7Bh0k5\+qn5n^-bP?xo^]wjN\>yn5rV`jjRЧlx2LR]c'D^*u^lHp8Y~V1E*7RA(\ٿV18zch߷7UOizvJȤSJͿd\@?0c)Hn~/|j7NেzM[ԈP:P1L%AâbP~|qmJ㻣2+g#NM2RM÷ v r;#/i}/YQD%"(5$kr*gGu;փӘ/ zHJО܁'SPjdabl/qHmNdXr?:jGL. ŽS.l!i>`ݬQX5c(npùQjnYK65(k[[ `qW z*|{u+^M3Ĝa=a];}|̮YPEZp`6uW(T&y`BKTqƙdjR[9k\ƅu&9$׮WO~*<w5OfcuMg qMe`Fau; m 85c] q #ƙ(AX4xEyw:Cԧ}h:} EQfለ2ź}5OGq;+? FMm1ih$+jɽMԬγ'Wym. úUdnjϑI=3x<ɡ_!"<\ʠZu&q`h‡!“ dh_?=ԓmu (Ç~(<njsY{ ن)aj'B`['rpL勓xCf|9D*ֺBe,; .$5Hl6wN2-aH"oor(ΩdVT\U۠ƫcGKa7ޗA*'|VS'6ǫ5([j UUߘTJCSm CRƅ!+hQfڙ!nxF!xr 3=* igC]FO,ۆKEqd ѨZfXU_GOApK_Kj2,{M @2 L۷'!w*_bg UG2w&Ksu>v~8TWVF',w1_07c>8OA IRN$Q3$C`]j0fgڛU HKQƵ*kIUPK>V"0vFatϘ.z`{'P*&#Ai[G7r(0r8'׷bWę}Pȥ(_;N2UH0&Y( ;(@æ845+Xb9Nm uFKƺD \^?=7)A׀mJ [zN mYG$ݰocQ3;4y7I5w'L,썝"h$a y Bvl6ݪ+4(45e}fQ! *;_FT38=j耳hǺhe:@Giwq*,EJ5jmԡuGuW: e*2qputO6bM1iz`HWe`nWX vVkQg pOm=T`^!qܦdFm=4pSQ`8ވC^sFJWȣ]_CqK92#ТtDA>(#qj `4\\N! ky$./3VǾk=(yg4]ɠY g=VgYS?n`(HHc\=u$ĩ!ʶ§FM/62+вo)Vsb[H۠!eub*RufZYusnr:^@цfs&귆(S2Ib+8-(AHrQ{eZ|'!־ ZH2)~cj/hcKR< ߶L Xa W:"*!k ~$i"GWK*ëIr6OY&B)X|,-Ƶb$+=#c-mg zb89GmvѕR<2}ee r7|flU7x樨n庱IL |pDDze#vndR `զon~i$פyEQH=/+{"ˊ5.Y"E蘷y&X8J4[aDWPC!xzRU 2E)zT{xQڙTxDPy>Qf>11Lt6"-G{ҟF&\ź :qR4" f*NoZ3F'.E)Jjt)ERvXſ.1ڢ "2%`#)_iR/aGIN-'7_I3yH,qLB‰b_\\̛;'6sAn.C-D w1jdh$R]T4IHv~aYԬrX؟B8Y8^ {byMs|PPh;QD @uӔڗ*9l>*I2f`ZQ QRj]ſAmHnN_U.@n50k4]y7S Ԕ|5։M`0(ǝ`uZ(7tl"4H202`Ki*^ $ yދ7?E[c^{!{ċh4Li[OCHQEaZpRR'e1+"0s0gJ4fe s:7Ў-JW ; T$o/G`7:56Ν,OV8Y(]TT&:'VUZ-Yj +Z4%`]tV;`G;1fe `24˓ioT"E\8grˎ3ѐ@Yj$lx)ڢ)'̎pNc*_M2^)٢an:go*kܩ,EF .GXXn}$ߣV`GC`-J&drMIH_9Ԟ4}k_WS8[ѓ@7h B?;n*wcrÒU;f$Uvq3%`[>J:Q0,ȅa#MD4~ՉS1)$'gʲ8 :v(7'P}-ʁ :X?҃K=k ^K!hS"ˮ;f9BN;0mz;(80{%gc&ci(jɖسsXҼiw.DJBєGNhxWGj3f3kWJM<֌@HԿ rDi/y^թQLO1q;l\:@Gm#t%+F*.݅":h .Usp27p&g!JC'b TQ:Y``; t<ԆOE/WRYhuϝF8ʶkX`b#oͬȖZʄv`6K5:R}$jD/ƆIMCM MAqh2eNS}%( *"iHg,7'Wzxxdr6^H7P [$L9΄ ,+XL~,?0LN2tLRHYjw~*5"MFO7JDV$Rgj YA\=H]:#IwGsZ*TћW1*˵ h,EJCb}%'cW*=`[EtRCP%iɁi5$/Dԧ18JC4D K'a팡:EL5h.fp% xLu|cޭf-ʨ&;H51`24GJ=:G"XMr5 0tH!0LNќ0u[Ҵ0I_)s/iUr&Y>8E5t~ O INعcɌ"&3l%aKnECji]'}]kɛoeP73"uۏijCra;ݨ޽Q AcLreJb̑=u9B/~LGu8nDMs~(G>p~3L&`z`i w;3_Mti0v$5[:)/UkȊPJv|\\UǓ@չH~@W=Wi3b9Mu;0T8DXyc.7\PfZ bOr)}vIDATx\wp]ՙ^+ْ\ `c0&l`%3K @ 0T/eX)c+a ["[}}wa{:464>;phkghZv8_`zu%hêśj*.+_Yƞ[Xl12nŒDf2hi0FX;!?uO@_}k_멶כzb+`E2⫝ 5˷T޿֚]PdLMK'd21HfM QXCn7C|MHB y\46i?OevԑLۮC>GF9EeT^pseI~~gp?BhD8w0CJ6 %_}16ʱc=槞z*!bcՍq-6::l׻y˿a|̙v)6΢ hڞR"qF~G4Wz衇_~KJ/[[-PIY e7K)]|u†D*"H[{9xP8tVy=FoN%rjF&嗔הڃHx&i䒬(ͅ^z%Z`2OsOV Ś<i'luP^AiECEجWQq]m[i]wE/FKᄤAfaD"xLYY܋`QICZz [J\Tq۶m9#8!'9-Av{\i245WyR*z{*HU vjNgh6Fs3%,oZ9.K63VԦe.FyI$KTu(ixJ&&&v ,壘17#n1=bb8ȡM6.9ϵ&Y$Ʉ-d9lo %p566dG*7۹s'rCR V%3gНw ;R)䳶;Bz ^|XFr {w94Hex^\6T⪢֎2#}MH]0aF1X8!PQsG!QQ` ̰&/t? )6Uܜ<ҚJ,$CÖWʌ0#+8`(@zaZ9}4$ (mGEN@lmgtUWRiiYET*H<)Z)X hJK 8ʊ@0z8kH:,GN8M~t饗… I_gՑCEM2h sϣ: RC.cBQ CZ9 ~OT0بJqJNp@ڶ5ׅqGNt0ô3{EjmY#Gx`ōdbPj7  t#(ktZ py=J0 -!0 ivV] u@Q@iU.4h?dt￯ .r& k?F?'N@9[ d7q'TqՃhC.;[Ȍy2c4v s}`A沯΂WMuSJJ$+V!''-K)͵Ӄ6zvzM*%R& MMMtI+=.[޸K ^vb?ڈC=~7Ӂ6G%htϗ߾~睞P* JKK2Vv 2ABI1ٵk=z: hupwqY>S :(=d 7Lo6qػ!E׆(=qy\]`N4[T[xQc0He2HoбcpB{q҇"hgq%TtB֨d8HcxΆ%v?P *FGFn^cO\`0Hlc `zo#}Owre\[Bm̰TZa?`T0ʣcEd#_!Лouo=;WK?Kw|l 8]YTYGMR.֭[QCS &J h_R,H \praɴ)?^QGQEmК(`z)C~44>{̳ot`AZ oGlP58p*ڂ!& av!ZRU{# L;jo1I mB^~5}||0Yg K"CׯĀŨKx7^ |kx1W-u>}`NjfzV^[{Wv]RTYO " '?Q+9s!۷oAmϬ#VVLVށݴopuBgTGa甮_Ć1[u%g_uk]ʆ܂R2lG*Vwss3dn㏵L3H\Ьs0 x=4쥮>izPK_ 42#U.K2=X6WM?ۗZSY -#xN'R/I@`a *OdYdži|{y=Dfr*:H?`AC4 xap۶leZtky ̺v=Q[WXPgB2R>/MOѸNw7^pTI!-X0MIiKa2SrSW/_tuoIM d2- Pic*9W*ٔA>|^Ms-Ϝw"јKI XZ^a6QQrn4y(vڕVfQjF832F2X"Aޮn~MsɻwG[E"Kk΃`%yn:l.?^xy}ՒYdg>bsh :y9: \nPo_`OWpGsGaf>1(x_OopԱ>~vO/б)#`iӁ&ۍ=`r~ c??%cSY=P8!a~A79@4>PSk@FkIENDB`PNG  IHDRL>}IDATxy$u￱/YUYKwUMӠH *pXt zDDQtv|(Ιyp` 4m7M/U]KRgd'Nvgռ9";3>qߍ_rabO%R !Emyl*e *@E\Nߋ O|U8 m{Cc>lˇ+9c{l,&YՊMd>48C$b'.s?Uo}4$ -KANuV6f=7e@ aEBuVXsPI!`h 3P$ "6>5 1 jQFM5[.L3tTwBƥA)ǟ)b.+g10 ZY{xb}cW:Niu€CJ FM$;F. 61/; 6M5q8o23Q <$f |B܍,G> yyIe&0@c#}6 v ֳn!X KCڶaye[ϸO@> *2&A$ ($Ldٹ^-M ~t@Dp%t jhFNrznsǃ/D:BP!`$yVd|@/? dBO@U%(uiKw?8``I~x֯)y؟C65NB#P]!I; Rfnjy)y"/g!pHUD}~1T*-EwbvIxip_wIHG\C`\q~d^r-YIˣ92&u\z3;P\0F0 ʇ^a7k(Mpꉩy]l(L=%#Xo]doqKF(,6x6B߂5;̎GTAkW. j:"J&4]ł B ^1:cf; ؍yم{^xm?/}FF ݷA ^wַ,i(~+=:"$}<J B j(@eruQc;86UwfpO3xӢkd5 [N2_}S05^ű?gMpRF{N/$οz8e7^-˲ L7"x'G''*hZ% -_F^0\Vv6=qzsC4C&WsLFDZIpZ\o^fO*EsSO~ֻ@ @+Nhi}$a?:=9fV0<]AFzE-0""Д/JTJ`<^|7ȇT4"m`Bp ޽59 5r." :dUT,3xdEx9ɉ2atv5J2,;gڧ7}ḬPeu}ҳe(y, I9Dx.Ic!ନTzl_=@ uJ`ukŅcy= nݰ^`2dǃDS+"$PhWЦ6i!)|6F|VisLY>j.-"h_?Yfd$hI3@ɫy08P[4QA93VH{""J(PzţO TZRm{Y!O^U#"Є $Os&gwOG'XAyڲ,=LD-_|zXVpLz$>m\;%慙e 8GuQ|F(m^?ԏ`I@Yp a_q;<Tlz|ƪah+isɑcr8e;HJ& <uKVKwj۰qOv+ˢ,Ʒm^6-8MS ,y9NRQ<ۅ8 P8* $Cћ}G!aeo&AeAlvqz^)Df-޻5_<mHF\J F3c __ "LvZ-ZٕȯC SGx~ DRN=#DM9b0)WEsY.I]oPm>kDx۱_ow٩",nMk:rE+̣, ,zO1pRVL2uB99踉H07>b& o=\spHVe ,a e卄ws['}?|S`!{^r>Acв 9MBEp\Y X,^Mh}Y6 ^݅pam85 $]@!$znEkWOR+_ ?9,Xc`&?:[-\A.UW |!XSMěK*AAGw,TBqD.w BVWa_#+{pK.cLn.n=G'אjsŖedjF30cTSG(ƃyvh0u ",dZ>5@9~@Ai_wrO/%r+=3Y8 *P6( 0^s[>g~t\k!y{~x놥7g^>z݂ta~HH \j/gFfB{ U',k{]a@T" `$X#VeޫH\  1pPb]g<;AX.JuV[Ӌ% _lGe0_*{$ .&yglTF*b&`쀼 `#A* byAG hV@7PE@7>6ׄe8|ձ `BN_W(v G[ >Nh+ؽg{^iuDw; h 1$ %sUhljT3pJE^ceDw t [w|hHCF֬_s'9jTx8&'KZ ٺE05@ˢXq{',rxZɄI!Y&ED>"*M7$t-WܷyͭJ:i2ȢCTjh(͔5kl>!rCMpb ə`#ٰ(s9OFMp>0{RJ?,]w|qNj~YDjݡl!}YȊN-g砊45#A0 iǓК7 ffZ8yW_Z\T,*ڠ"+As&w\O`YgFP`TN|-Wo۸O2LSCxHu|ς8FJU+r$x^ > *L7AȮ_څ.3(<*%ǎwZNNkAEh% &O S5Nkf'K  ob"fm=0OR%\i,Y~5w:ӣtM³[.|ߧ./̛e4$"~+Ҫ g :֯nf).jy5 -Bu&OdMya+M̏~o/b/GvU[?mHϦ\V·T2=&vc5mDҳ*efXQmxZ I"-IPt5}039&"?>S>yOK3I=aZ@۰˪kQG-.^S2Eab'rn/!ЖQZ0N)Sbhm[7zڡ#溁VZ̳ R"^$sR1 1Ӌk*{}csN:+/9R m{޼rÊeu"0ai6 4=C 4YOexށxļe`*mnX_9 C9 hՕSe_,P ֈl`9"1`4 +Qlu4҂]Ⱥ&WHrIENDB`PNG  IHDRL>}#IDATx^ ]y眻wu[4Z[lBBBl6 Y0OPN P3c#pSc `){bfQ ^bhi{.[ҭFݜWZ (G =rMt [9hεt_՞bpXXQNdI[|$DfSjχ7?E K7`wǗ-]Ӏ.rR%g$4)ڜA2Xl29{MZe{ xM*&N*dmfl~4π$zq[~G6%qq() kOz`ɖUd x=kr@ XB=q@pJ-ĵ)ܶnЎf󩝛_B,;5J6=2=)iG(7ݎM Ă=|)![.hr'2iqXjYtp4Ne_?pp[IX6YKfs֯l[r4 ^ ~י*HD UfpU@>Jquh^ ׻@$b%P.SXL@R{{Ilv i}MJV_2ܖ$$x56ksiyVA.(GSlQPk"3N7x(ۨ{ "f Kqq\uQ;"`k Y g)z^cSI%ؗՉG<,<63p* T0vsR$S\O7n斶c %Dlf4G{8[n9[}ꘞ.Z#㙝RXnAg06Ҽ]F}hh28F?y7TJǚ΋PN&l?hC56XӤ-n[—ZQZ@,->σPٱhjt@[lm JگLf5 Gc*^NH86JŒ)>c'vaIN/ <% d!.N9M"rhS(`}g4|ɄSZ@&й<((A+(tK3i}b SYuzѸ9ܮ̰ mivRycG; 0.s)0~^ P4%fQ̋, ~w#x]m J<>ƦC ţYl.J S"(zN4C{5(WRxK -[‰Dʀ,!4 0NHfLtvlMs9˜6Z7XhS=Uwn$&bkͦ8\pO/|ei8*2?"C ,/5W vB6ATfh{[ ./?{Ԍr]" ݑ%ھͪ,)/Ta)aGFkG$z[aooタO|l30h,op} E. G4[2! h5leoT^җ EHlY$qFF18{ !AGQglmeyIUrSb `Ø`F>ze(CV_x.Sw굸b)Xo=F:0F7gB l* .#MA$&& _Ą@! `.i8QXɣ'^9;?pKEjb<>V VЎ P@a OgMio%A\אPup]hځ#=c]hF1Z+`r^hL~@A`=!L)aq Ğ`F\ Ac(}LwKe$U$P8ӔTeGTEqɸ}S?_ZUcQZa<3R$2mʷ[fX*+肇jnkYllQF}"H'D8Lgmrh]TaD3{{):h$(#z{$nWBiHd94f%Uki<#\mi @p v&6cƤ&m#U$qxqN h0(.7+17?ɝT;( 8mE k"ASqAsXrv%$ذ(ja n#VоIԤsN.+Ԇq7oaaJZJj]v||7UbV+O3ӞKjH?mˢwݹ}|㡷XR=} U]YL~q{+CR% SS;<|Oq9w27\ن>[[VBFGm QDs?ǯE|J$9m,clRMj(~gr=3pJ](wwmy>40 eSMo:}d=姗`d D5D6!nV*(izwP.uS`ci_|;웯<%eQ~Sg\oyhwPa# Q5$ 'l=3iWٮs+n>{.B9}z4Mͨ"~d]Eo(# Sw٥#R;^G'$"I ((&oT:Q Lz*JPN;ZA\"d)T4[pDKќ|7v=t]ii2T{)D+p}/xN~ Y &pK(o !- - vxc+NJYw۶/ÛM>T?پ>[ޅbQ([@0FH,q=BbDҔ8D514 4%[?;Fo>c#/V}gSr Ƨ3 ;u=v|g !X+Lfzo|g4Z y@BX{\=TBҤAzhiJXFă?´VrΧNw.O$7VT`iX< v4RDC"˥}IDATx U{C IXCGEePݣQ@gqp\DO!a B @Btt}yW˽SzpOq39ܪzUU[Ց88vS_x~AIXP88jع:TMmYHwt ގG160. COG~;2R\AVTH,vȪ2q޵a.F4#AI5&=V&@?j2$_2@eh "H y7C{H!93`fe 2;l;-{3g1_\hE;vn Lx$tt6H228ǑI [{?-suW;!5La͏[Z$*Ϭ[ҹ%1%lvd3{G j7GUjP`#Eܴ !lr/ >QC=v7P:c~]7}GUB~Q+ZWBR$0?kwl$s=)o]]$[;X * 'F<;pla(IwCC q-[meV5<*NUXxɅ(LcdKwZxp Naw=n-[`B7 [𵏬gIg.o\ V(F΅Ye8,*y+??ShW{@HF֪`z| /=Ua/E=-.f<vBC9x~'k6AI\ x ~{N\lj-X^4 +o5PГ͈7#ZG8GcC#8̂gGG1knE4&eDiz.Wh;\8V]w-_vmw"԰}$0OAqbO\ȍzxl1Jx Ǒ+D '.ʐԘLbʪ 3|  IFֱargu%L`3 ɝk.@ˊw Z5_UFc^rߵ@o?ɞ f ?vwBEڛAQ*%hUC5V )J!38zW*RM.dԵ#zPt,@$^z:vtg-] @ݻl̠Ȥ ,af[îPB$ 2f0RKa$PC,DۻBԓmkF~zQ+/aKg(M:F03jBf:5K>ߏKSH{ Xd|2,Qjp:߃Wm8 PtEJ*nENtv98募Eφ>m%<+eAJqqM׃%N"x,Y/oOine_hkm8jh󒹪G1!v$V Q8 3WDko9c"`6dYCG/])&dU"J\x?*,ޘBQpcuhyҙ݉0buTx]mVÈ%AቌN:-H$Y% vh(D+.Q[@)3C2sRN @.\%R!'xb;.n\Z$?Ye"fx M (j)@hͿ0VvE013|2JBAKϾp _~OtaCc- hZX#4E 8Xf P ȑfA셲;?߈;s"M0)Bo}Oݽr݇w-{Bjc6ꭌ>02?$]d!f"?z;Ϯ׽߸i2dz.&KX2Pk`? +o.x^|$#X[ .[#oW럸 3V>M@0gLh[{CVs$"BUgT:PBLÄo湍N\E9 l2d(,1|(`s@ MRaZT(ZVP*Go~#^Yŭe0AG bDip攦`v%Z41Ȫ!.rO}6#nPk(亅. {y]xtV%%>g Q+Uѝ`6d+% 7 Vvs$OͽHD3QXq*-~^~9Ca|K?{7LݬƒӛCm9Zи@W,JUS4y*~Ȋ+[ fUh~*`W6OCԳf*0͔]O΁qa"‚$!Hp7nVhnٍ%?5xv)҂tJz=Eï% 遝[VF탡TPNn}޲C7L z)p3-=l|=4U5 a1ݴ'ޅ5*xWITa!czX(UF2쇓&r <WNN%Gp*TFwaOu*6Ԃ{N/ JY&PɎ<V@dXc;]X4dF M0"avo_xZ%/queD,bh%O<vpp,J3<{vafv5$]X (5#+ @QdMjHV/Ú](H4ǁa8I,Zeg{3hvUYUx،Юʘ0p8( |Y*oj! $]&FC+zgI޾B˲aaf$37#!Հ6DP1CreRq&(WybR[^8p;H"X} z@,]P].^dH slmB!tM1"NHFM"XJHuk {,?U*iihm8P"dWQV꽃+x,D/۸m絵&Z$۶EB4Ĥ[ yĔ:VdsIJl>22$ETd0y\]%'6Ӑd5 ng]6ëpq꣎EfxSSG\|h=kKG$E$s=]mh\ЎxK@8c&d*ԨF"5 Xᴬۣ $b-6&"ڒB}g  C51 ]\\EijXodrfe&_g q[h^J0گ/UB\H l UI iH2'9鶠&Ă ew(Roo2V^V-[na ] ,ޘ$Y=x/Ǘ "* 0 qUT6$ h[ )Q0岗  izHܚ8 zR@orB<f8vؓG-i]+m&F}o'`sH3OQϩD4?>+%I i]naB !.Γд4q4+mgY3$0GFv獷z@>c!Y!Hϱm0UK;~;uYB!%$skP&A!'O; prp*Yw$- iئ [E8]`f0 rNӗn{8; '<(se>LU_K%$O|%kמ&uLC> Jb$U_!uCMXi4?*; XG=}]?c6NWay` 4AzUee!CC?5$Z}m=޿GP$T]q`4œGaE/^ky|HҎRE𞁜9߼}m#Ix L$o* !W$\ ~%EPŏ%3_\ghTA,/ɮ5^1hwѩNIENDB`PNG  IHDRL>}IDATx^͛TTW&j,&b-(JZV@ * Q" -XaD,&~&}os8ޜu }ϵ~ ܙ>Q0{`{X?yjeKj% vɢ#OȧNbuD>atص:LtBmcLdBbEZAZ1pm"ߟ{amD|$^ tG@zp߻w!"9xٶ"xZZ2PJ"/ h$u!HoDsx%>I!gyfEYX(ޛ<QGz1ݘD8T:ȞGg$6=W*W¡p"w- bB-qp*Κ {Sasdٯ?؃ ȋ[%崃v #HGXTCLdȪH ~xϤVD)ؙ^6xΌ{'2խ7+׸{˙ ŋmFם6^RB1!LzՅ1 cJ+ThGmy;^ ooo>.m^B%;s9_+N, $)Wa^Kfc4`̻K[“keUռ0PW lxv%Jp|l<Ɂ݉3=tSmR#)F@^DtuHJ* W %D*͍rC sq}R JxfiqgCm4-+Da 9n𸮀h7onn S*X+p$ZCGh( xamt+&i!s©ZoWI,rT)XAKhbIgukvgAlH Gam|&NQ >a̋"KT`J̚jQ^5A8_PX9c!l)>Ro!Fz(&݈ja(BMLݕ0MP6 $0rh#Q!q8(_5 R_a)!- E:0':M"2#dϿW%H/1$'F yquS`Ѹb<-a`X hf#uH"մ CA<3zpԁx/LpP̋H>σwvȈI#HsFKO~ HZ S3+cPvrfңܦ|sh#LlkP Q`؆Gzn_R IbWD1 oxOFO)Q =;,c^(W OH{ݴ#ie+EacV!v5E0f <=}a:DVjTeGClxЁSq:^[ xBz}1ί[9в \Iˁo>LCi3QZJ=1J/|Vu$.PFE>:Np$|B }z8\23E(Oe`?D+DWb,=-ʚ=ղhP"Gm=4A_O AG[ &PiOnS`d`M1[llC~`magr0kܗp0c]yס00(M{t0bJFFM{~5څTTXN=#*Ga{rx'?ÓWAGżυW60yRSRykkk<aDl ;⏵܆i,T ְwցt3,Q-Ro! )2" { AIE֜'''x  3c\Ţd.RIyw{+') [M!_}mm,!%kQPWGp!(M Pk^ :hT,OGDV  3-_xM)>@a fff?Sأe񼼶~R0>}֮]KT%аe&\AafE9 `0||,= ʄuun q?.l ㅑz$Hsy}}4 ަ۝ Ÿ |9#e('])ߟL1ell HSr jv-g5٢7xң°!CѲOY ps4&oSj#p8 ; # >j QLM #Qri[y9ҚJ%.c#CzNEpl<8 {%(, 0^8,AbfYHN`h!kolk1BhF9ɬP_r"Tzgwa:أ0W+Ml1a|Bq0iH@½iΙ:WJf` ןÿ_=zŧFB'LcZZZH6  Qu9Je=˅_~v4l {W?&3a.e LD.,g C-m# ¦ۚB*?\7+,!޳N)JM[ţ58::ABP\.ooossstKV&xJt@%Wv!t7*x@ȤDK(]Z0SD (ƏFl 4a&Ö^PJtAڋr4qBY۔$q_CZ{<QFё6k <8HR@\v(0R Fua9Mt}{4o+ Neʡ`3 ҡi0AB(i ѯWYK]F%&O8+msZ&0iqakji\*}| uLBQ2TSf|~Đwþ`}9bh IKadd'0\^k۲wџr(Q0%LhqPLJc\p+.1&$!f.c~76쏌r:-ҡ\hqS¡49\.pEV9$SCA N&XG(KpeTōAkT+R,{b[/Y2C]@ GJaN>EEtw"2"}s5 hᄩ qw8ŇJk+K[ًT'{a4N&FGHeCQCJXꬖ018~y>qϦMu2?].Wϵ*M!ؙMa9Q0alڵAtC<XoPA< *S㖣җ8{Y:J(BQ)1>>ܟ6VilSoF [?3.; ϛ*pcJ#lY_ּ+\X Ks`O&L}nv K!x_rC:nr,^d0i,EJ4^$LOata3BQv}ɰaUH sQ@axMm-}3Y6Ƌ$(42eGR2U\5fF|q VC]+~c, /i i;ea<eƠsL&NHqjH I7]AYL5|( y<ּ.QSrʬ2beWDTjZ.+PT^KǚL86B [O> bo;axѴ֧u9ZB]wc[͉S.b6- c%W/s؆=+pj%+;{a%P'9ԥ*^ctbI 慉I=:+ tw*wH5U `[ 5G5ѴT)bE0Gض%M~ 7'ud5/L e(%N6RCijΙat" D}q*k5:Vϗ!Z9#~%OtrېOKQjƋPIQ#9a4X&=M) a i&Š唢WZ*&g0&ÚLeLh2?.'v*R!rʈ8Un"LhgcNIENDB`PNG  IHDRL>}IDATx^wl}K.%R\uw%RWW'^W<nq\e )5l8SL*0 (:Ѱ OjjjF%ʓΚ=r@8(ϝ˙ZnJ4H$"XDڛK(2Ãq9?&É~%X{vSjHXEGvS Uu+X-`N9+Dmvt˹a%Q1jPw7@W^TǰF(?W{Ӳ_`;<a_l"YEPmnCX*`ԙA7zIGdu 9L\ZqA1t)۵NH`]u*P%EhpEX}-h%\]Kl8R$.0ҡcҠ_}2@`̼}Pvqv|5|KĹ@rO.]&:,?W2˓#BT^"Tc4^Sk?B6jYYYԕ4wm.S!yFPUZ9n+\Eٝ`+[ tA^LƼ)@֗RIÐ|r^8Y/hpJ75tHBBpgTQcZ ;_4n(>ION$=re.a1w<gX֭{Bt@̆0Jh̄LNiّFEÜ*hAs-И0u Zၘ[^dυN߀,00BgSCm<$-NVd Kc g᮹񵰨/UVi0f=zq:b4XG4)'CNfny<0B2a`B$rD|Mo?Lca`FUk~nKXC\(ZEl;˴LP B3f|u;Yv1!g![^=Dkl-0Nh]QQY|,ɽWlQUBRg߼*,7Yte eФ.{Hp8ϰI9zM00"OoAk{ʐ'oѐ<8,2 ¸EhKuCrAk2SB1GL{o+w' i+'&+Κ]v9Qe!мo603&%;eX;jAyZfaYBHl һo? _XtvCXG`T"J_XDbcؼ#tŃuve>Jph-F0Ӭrtrcsq|_+ ک[Z]^({V&P]JHY} ihqme,]_ukXfuDHZ#̆쭯~I^j$K3n.5ruyhL;qd0̡?]h04۹Ф0~5ITKB4D&w@., vIw]Ex`tMkME|nꪼ""/X-'6^]4z%̛CqcU֝6] `rxŽr  s$Aiq=TX:Vnl I!0w`TМ R&k@H`{R.0RZ.Xa\ Ur.'TܙL.}Hp*pk8(49>}&oXTBZ+\Da<=Znc>8D~v 0@U3_pY/]vf!,Y؄e怙WW|a$ZUI; (#6Su ,@cHm 8k#W:&,&rQ'֞,Svh"c hxy va}e6t:$=J.C비aVm:Q-R=8* f`r&Մw8fNhGTH+_Rp:b.Kx&JPs0RciXֽWlak",`EOeeW `B-P:`cݖ2F "Str Ο- ,-V J9eIGXHpFb'$+dX-!i% ݏ- E>.>![k@! 8[|{5 b0TDg~0: QE!i`KQ+&[p{t&P!#5MӥBUr]tR]ɟx`4ޱw']DžbgpH(=NFwa"UX|Ż 2L20] ta\[im^XTydPVP@G%xwZRH wɄ5e. %z~Oz`[,o0e-`{cq'/a ֫Y PXGda$*G-*9\k6Zgū9M,5`IuL-Žeoz:f&i0H!9>HQ2 8վTB0Aa`K`OpjW$,ng^>ppFx[smsX;*X X7̆V݃v)%Bo3HdFX Vl;341g gS>̐yGdFPqFg5u Dm'¥O4t:6Ṽ 0~m#EhT|%#־ZZf@`hi -iqa٥/ XzڪEjAep@m`&UPjmŅ_{iN!k`` 4 %}P~v&asͨV1",z!:0oM2e|wD)--!? + N$L<5|OeJGp +hv~+43,TfN t503\VE>C:LivܹO>]#{u]Cu;~*|5`-2z]\uNmY泐6Mܧ֬] k CLФ9iWuAQ%k;x lLfUպ]R7A S8X IENDB`PNG  IHDRL>}4IDATx^iuoz$!HDL61`Ekc $q})csc. [bCdIZZݭꪮ᫪o8Zߪnljzj}|?%RVݽ y}Vb|g\d/Aj4k%/?NҌs9SJYxMy}EqE)W, D']St=9mH2K@d*oxɝ~tk?] _< X)_7}%W>l|6T(2M:͵B hh ~pe`:6eI;8?4kog{^>%{V/-Zbڊ jTV Ae (.H%0u$-TNvy_u8QG>U\[P-BJ wll `^Xi9"@;w>nå˚Id '!ΒF2s^el9l@CJ @5`e9@ML%d< uO=v垎}韾zŊ_6'zrVwZy:NnWȏu 0KK6B%`p>Go|acOWQoY\sTJFQbI&E8C4Pq jUD#sEՈ /Y:򌼍@[Bҋ}#WU$Pr (ۈ8Nv"'[E *|߾tTQt4!} MZ5jH haYI1v61l+k;5DmTUKUEdJ.AJLX ( |M.(("d'*k@o@ Sي51lWyPMS@ X>O[oSB'9}6q~{9ַPC0I@*/8[iF#qb jJb(a20Z${# D1G %̢Dkjې l´pCXs?0& U_xk;#N>nbhGʹ3I"¤S;b[y9WA9BCe`-(^ikt=އnTU@ͽpaez$`2)=O,  i254#h*L.WR)h]5$ 2C}(6XWף4!ED-`gX<@Ϳ <` !HV(ˢ,=w<4UPx-[v3oj( YI2 @a,cz6قB%ogOA4Ҧ4 G[pHz a1y I$u9O\~y/?n~PHKPH9uS8i1#@Lusu:TIV( /l|I'prHa L__ŭϭ!C** Ե` }1|p0VUT"+aSp$BEE%b; 帣&Nl'5 XV1̡cCOiecr,O[ŗ Ǝ D iTF]Df"G0$sN2±rP at=PT\h"ɜK4-chFS%6Tv@ 6"K؇11B1)f3<3yw?+z~h.0 /}6܆5 qe~1=OjeML95ea`EMDRWS388J(cʋ8HmZt景!;BFAPBO"dP/1pLv.YLage.]]vn{Xͷ\:p9׹Ώ?c#%DYRV$Ѹv7c6ibtTĨ'7v!uMO1Oma'a$F0 XF4X´1iˍT~XlyQk@vҵWoXH(P;W2'+21ʑJ4^JOP2i5qު%!ԑXR;j"rC 8sVA{襁ol ߈G"/d(?\Uϫqs @ӜUk}PRfQ3Ȳ 8#l4 I-\;@sjpp '*?Ih> i.a T I  lAHm'tfb3n&қsG?a TU蛟O~ܶ֞G-Q|MB>2VhJ( ˰3&Qs`Ѣin=,Q0Q'qSmۜ{+9h*‘H[~ⵡw0\وR9s _.SK>z?webIS$IU$潉xY͖e"X$'uFмV9 {VU,hZ7|w; Zֶ oZJoH;8>0o:s>c:TTeЌ2}KL̗\(h=׮]9c)X zI@@*2Ҽ/wpWlѦ|`4B}|lQVUЗ3>qZ#ZJ8$$,[:r!0-[IS;ڟ;{)P'z]Pjf0d56ӥxL;gɷ<孪101 ӡ !$"S0XXמ/wW'A%o/Jri ~@G޽u %d;BuYq; '32ttrС{o;{ײ[Vwjnha&Z7ſZ^oxMg_8fpd??:yI5PU!0o9>urMf&;8^ϙmXY zCEK}YF:HޖdNx bY l;D˵+P#[Dg@t łu׶߀ԝXHCUd4;PN1ޝ@䌷v3$`=ω@H~!vL7ӝ:#usGTKN. Siex,}`fq,-IT`$5w`:.sF}]3tUxIƳjC tRR OFC:,Alۦo,K_JrPW~11n03~)-2I-R(B @{ũڔ5 o_|m iRlj2"] N~1@F߼^(J8_(@Yw`(e0rĮij*N["TV^Pʲ9k4` eȻ>`^O' 5FKp6O,#hJL (MN'#[p(YCR.nI#ճ..4n,o10fcR|7 eKlݱx#tËz{\LmDzEr?& ̯ᣫrGWke]+/%\nqӧC:yJ#uo}w=[/jE^U-]ߗ.%_CmT#)(%`(i3R$o 5sc~d0?1HǛoEǿ3 ;6$ɫgη%(G_yG_;^vuLɘJ=]pwɒ& a._D.pj hr6GKz#z٩Pyk[]uE͕TG)4IyT; Ehe-<<Ȗ߾kl2C}f-EÏԉ=.n\vKϟTsˍHU$ C6 dj*Z.eP$|s< 8~7:m^K/Z^;wҊښxUMke$@4-E H#=#l۞Y(^҃jX>Jz˺9XZtq$B$I1s#=Gwtz#jJ_ (ܩm4,?Q˒Ke?p|:Eds%M1 4(x{yˁ>hSܤΜjȳEIENDB`PNG  IHDRL>}IDATx^il\ys6;g8uI(r%Z%Ӡ  -PSCiE8m6ͯ@Zr:1䨉(U\[-h(ڸ8r{ES@/Ό<|,lǎPۭA3) 08U`/]2$ȱFOc0Ɩ'8OWX$UҷEH}X<@Ru['+&`ܥӧref;ppdX}gw${5C'<ҏ% r jKLSEKc`fes~z؏>` `Frܤkp𱕙/K2PS%ĚB% 6&ݔJu'|^`zpE0vSUEp¥mܟhM_WR+\яdOM-QH2-bY+p)s\Z {Tv: s\h#goȬֻFրbg2qd:㘛< 16 ~Av{1"7Hp7Ԫ݌Xsu?e13?%*Y*#֑F1j}_pg"0Boq%ܥ#)H@Дrq!8 y墍 `[:c 2ՍhG'h8=y{FpnHJӓG7BjJ9 lTmTeQs`'86&X&"(8U7n{z7Khxoۉ+Wޛ[99^I٪ E!Xf^B$(j 9$u% +*ö` Q$U|^ۓpPdV)L dzOe0? ˅$\PC)>k0d5ꊇ+̒"~$ҹﴴ ;5%m ɪ@&1&ș>N_S$xn IR@L(p3kxLЇ݄Xp9sһcO&` @Z39:w85 m~}>˕eJt`'SDSS`>Z׿ ofoj[)L ,.#wm`[zNB"pO}H!0 4sdӟr\DλUqv>-ޅY#3˾^+Z rW \xf=_5D `f1wPszq͸}V0i$/ 1UKR(̔$J@E

?拟R-P7|wD`\Dzًϭw8=;v׿W*M(| >e3_yMWkG>v%Z2 P_v|'.|`tǂ.3n\}o.^4",QЄxs ᯟ5.jJ'exIENDB`PNG  IHDRL>}HIDATx^y]yƟ;ݷ3 c16¢f! 1MR&iV@BJiP J $vnla=cgwr֯s}0Wz;y圫QO_`:% EWY.^2}5v7F 3?ݷkr <T/!{P!$xA' Xm)xy8Wk &Çl_/ 4]U` )`e\Cl"S8]+kW{c=ƟABk A1uOs X5(`$p׃cUyNpiP! p e<']\{B`;"bk;v!0 V%8rh X&4 c* t, lkSz:48r; /abI,, ա rC4׃AC1+tyy6@b0 ieC%̧ yJ8{Y]?*"#0m8C<D@ \rC CAk˴ P VdB{&p|h5NkTfTn#%Ê~tlx1C1$8 @B$:6'Sz$ڢ+3Q\8V(LtjNsBpKmh,M]B9gr 2#8LAJaHH/XTv5j$eH`Nuimi3?5m|beiC 4*~`#x[3<(Tw@ P )dKg_y=XJ$5% Вt ( xV^-8 ^"KOԩcg435rם@q= 8AKyNUвw C `2c PkEc@PxjF +^/izc1[|aO_:QQ7Ϭ@% * } 3A CuL,bip`C2_-"4ۅ0I,siba`-~.jٿ>L[{}|nYՁ޶8Q3E+>2~6})>,>v73Ϝ1*0ye4 oT?%E=Ó7:*ϙ{I^H飷]v5HHU3Wѓ5+Mض {׶廐U]0)Wv9.$2X@IV? a7~m[3tͺnte UW70,S98G:kXכ ;p׎!Op`5NzOj X@NUAE$E:LCR_ \z^'Rbj𰷉ZTIxW vxw_s/QpiHۏㆠk^{-$ ղfݭׅL LHJ(gyE\}- K*͢18 , &Hؠc t5Lj&5-][?wɎry|fxdo<ݻ޶44%j$$LC jpLᖭxlMrGmo.*!"<mMԜ XmZ, yP(bc֫e&)lymZ</LK_XywEЈ%07g$`%9&q$!/lPu׵kRW u=CCUUP(* ՞!!04:B`57-7w|ל܇ Mח4($P% y&gߴX:]%8`[`~g`R!~o?ruЕԡ)d \@Nٲ< /^tHh2=߿yu;O,{),Zqy`G&^'`ۑ_,CdbI{?? `[Wvk"g#<, hFɐ\PTc5p ߂go7uf.$- gOBx$K4,Z-Zl(,929SM ;H[_٭ۑJ>CF0[lwN*|/$Kj db:6>ftF3-3. &\Sȇ%f5_<8l\FdG< #5aɎEM:ekWv%ӓIh q9P-u?1dT @:G_k ?˜ 1Ӱl ˙WQ`GS,Owr=[- DC`Qn F -nʑx˽h/qF.`$E3 XD1ޅGq]ap9 9M)ކoSLFʔfє%d:6qXD5S&]FAQU*.1PBjh9M׭G&*L TpFT R$dKP ao\ አ9"$l 1i(Xۛ k'0M"`zȻsb:OIפbS5vV*ikw~e冡[Ww.tfT(Ju]H/+΄fr+$pYT%t.SR '5ɨV⩡+7}9D˳B `Nz>,`y=tWS`hurU_mhͶH&,4VŁ *ƾz- dHvPU !ٮܟQ MR⎐MB^*r36uec( dUh\sW޳qh7%7h<+# #ʃZ4 ̔He4kAs :5ST ]$\n(`KnK]́!m>rO](xζ'2@SZ:j\ x:)H3Y+ hJ8iu͑1|*U{?g9'fm]w԰fYޖ !PHs&9De::5C.qlY̝<1)!ZR/?02UD_ttvx \4_85Ji˕0  =}!&`:ӎI;819}?JBCLEz喓1-޹' ]QJ&3{ȪXJHpV@qV3I5Np\YhUMffG@ȬL͡bŁ7sGݿ SVtc Я7z3FT}q|! AAf9ʏ_'}>)FuÖ/צ#fLXE~-m6;_/rFS$2? ʐ?`ёQB^$0'V!IJ裻$sqO `;J-9ǯ.蘅 B=^WT6!ϋNT kKEpP'=u>0)Nrwlov60UͩG1f /-3v|Q@S9NT:1>mTlK*`Z6]qBB+G8Ku #0dkkI4NI,%E=g]o!Vtܼ:D ^DW+t5jIDATx^MO@Q`XE@T5.01AEJIa&M5UK[r/Jw'wf*:үeU􋦄yU)a~.%O6 LX 2%W ТÀ+"H0AMu.8aA!. L10A8i!H%CQ Y3LSLΰy,oI9t|aBD7R'BBjx)0!&߉N i44,:o8}bi )+]ڗkCS10Swʈ6^B6bˈA3K|Иˈ`13EӒ$׷ L&,K,‚9 $7շ}ϙ0ҤCsFL'A'y 2fܲJ@PsŬf;"l0˔fʤ&d&D.%rFשr&x<B$+acɮNQl  $2bˡc]_1~2`Yo, =6I,ImpBkwDNJIENDB`/* Copyright 2017 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. */ table { border-collapse: collapse; } table td, table th { border: 1px solid #777; padding-left: 4px; padding-right: 4px; } table th { -webkit-padding-end: 16px; background: rgb(224, 236, 255); cursor: pointer; padding-bottom: 4px; padding-top: 4px; white-space: nowrap; } table td.title-cell { max-width: 200px; overflow: hidden; white-space: nowrap; } table td div.title-cell-container { align-items: center; display: flex; justify-content: flex-start; } table td div.favicon-div { height: 16px; margin: 3px; padding: 0; width: 16px; } table td div.favicon-div img { height: 16px; width: 16px; } table td div.title-div { margin: 0; overflow: hidden; padding: 0; white-space: nowrap; } table td.tab-url-cell { max-width: 200px; overflow: hidden; white-space: nowrap; } table td.boolean-cell, table td.discard-count-cell { text-align: center; } table td div.is-auto-discardable-link, table td.discard-links-cell { font-size: 0.6rem; } table tr:hover { background: rgb(255, 255, 187); } th.sort-column::after { content: '▲'; position: absolute; } th[data-sort-reverse].sort-column::after { content: '▼'; position: absolute; } Discards

Discards

Utility Rank Tab Title Tab URL App Internal Media Pinned Discarded Discard Count Auto Discardable Last Active
// Copyright 2017 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('discards', function() { 'use strict'; // The following variables are initialized by 'initialize'. // Points to the Mojo WebUI handler. let uiHandler; // After initialization this points to the discard info table body. let tabDiscardsInfoTableBody; // This holds the sorted tab discard infos as retrieved from the uiHandler. let infos; // Holds information about the current sorting of the table. let sortKey; let sortReverse; // Points to the timer that refreshes the table content. let updateTimer; // Specifies the update interval of the page, in ms. const UPDATE_INTERVAL_MS = 1000; /** * Ensures the discards info table has the appropriate length. Decorates * newly created rows with a 'row-index' attribute to enable event listeners * to quickly determine the index of the row. */ function ensureTabDiscardsInfoTableLength() { let rows = tabDiscardsInfoTableBody.querySelectorAll('tr'); if (rows.length < infos.length) { for (let i = rows.length; i < infos.length; ++i) { let row = createEmptyTabDiscardsInfoTableRow(); row.setAttribute('data-row-index', i.toString()); tabDiscardsInfoTableBody.appendChild(row); } } else if (rows.length > infos.length) { for (let i = infos.length; i < rows.length; ++i) { tabDiscardsInfoTableBody.removeChild(rows[i]); } } } /** * Compares two TabDiscardsInfos based on the data in the provided sort-key. * @param {string} sortKey The key of the sort. See the "data-sort-key" * attribute of the table headers for valid sort-keys. * @param {boolean|number|string} a The first value being compared. * @param {boolean|number|string} b The second value being compared. * @return {number} A negative number if a < b, 0 if a == b, and a positive * number if a > b. */ function compareTabDiscardsInfos(sortKey, a, b) { let val1 = a[sortKey]; let val2 = b[sortKey]; // Compares strings. if (sortKey == 'title' || sortKey == 'tabUrl') { val1 = val1.toLowerCase(); val2 = val2.toLowerCase(); if (val1 == val2) return 0; return val1 > val2 ? 1 : -1; } // Compares boolean fields. if ([ 'isApp', 'isInternal', 'isMedia', 'isPinned', 'isDiscarded', 'isAutoDiscardable' ].includes(sortKey)) { if (val1 == val2) return 0; return val1 ? 1 : -1; } // Compares numeric fields. if (['discardCount', 'utilityRank', 'lastActiveSeconds'].includes( sortKey)) { return val1 - val2; } assertNotReached('Unsupported sort key: ' + sortKey); return 0; } /** * Sorts the tab discards info data in |infos| according to the current * |sortKey|. */ function sortTabDiscardsInfoTable() { infos = infos.sort((a, b) => { return (sortReverse ? -1 : 1) * compareTabDiscardsInfos(sortKey, a, b); }); } /** * Pluralizes a string according to the given count. Assumes that appending an * 's' is sufficient to make a string plural. * @param {string} s The string to be made plural if necessary. * @param {number} n The count of the number of ojects. * @return {string} The plural version of |s| if n != 1, otherwise |s|. */ function maybeMakePlural(s, n) { return n == 1 ? s : s + 's'; } /** * Converts a |secondsAgo| last-active time to a user friendly string. * @param {number} secondsAgo The amount of time since the tab was active. * @return {string} An English string representing the last active time. */ function lastActiveToString(secondsAgo) { // These constants aren't perfect, but close enough. const SECONDS_PER_MINUTE = 60; const MINUTES_PER_HOUR = 60; const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; const HOURS_PER_DAY = 24; const SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY; const DAYS_PER_WEEK = 7; const SECONDS_PER_WEEK = SECONDS_PER_DAY * DAYS_PER_WEEK; const SECONDS_PER_MONTH = SECONDS_PER_DAY * 30.5; const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365; // Seconds ago. if (secondsAgo < SECONDS_PER_MINUTE) return 'just now'; // Minutes ago. let minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE); if (minutesAgo < MINUTES_PER_HOUR) { return minutesAgo.toString() + maybeMakePlural(' minute', minutesAgo) + ' ago'; } // Hours and minutes and ago. let hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR); minutesAgo = minutesAgo % MINUTES_PER_HOUR; if (hoursAgo < HOURS_PER_DAY) { let s = hoursAgo.toString() + maybeMakePlural(' hour', hoursAgo); if (minutesAgo > 0) { s += ' and ' + minutesAgo.toString() + maybeMakePlural(' minute', minutesAgo); } s += ' ago'; return s; } // Days ago. let daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY); if (daysAgo < DAYS_PER_WEEK) { return daysAgo.toString() + maybeMakePlural(' day', daysAgo) + ' ago'; } // Weeks ago. There's an awkward gap to bridge where 4 weeks can have // elapsed but not quite 1 month. Be sure to use weeks to report that. let weeksAgo = Math.floor(secondsAgo / SECONDS_PER_WEEK); let monthsAgo = Math.floor(secondsAgo / SECONDS_PER_MONTH); if (monthsAgo < 1) { return 'over ' + weeksAgo.toString() + maybeMakePlural(' week', weeksAgo) + ' ago'; } // Months ago. let yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR); if (yearsAgo < 1) { return 'over ' + monthsAgo.toString() + maybeMakePlural(' month', monthsAgo) + ' ago'; } // Years ago. return 'over ' + yearsAgo.toString() + maybeMakePlural(' year', yearsAgo) + ' ago'; } /** * Returns a string representation of a boolean value for display in a table. * @param {boolean} bool A boolean value. * @return {string} A string representing the bool. */ function boolToString(bool) { return bool ? '✔' : '\xa0'; } /** * Returns the index of the row in the table that houses the given |element|. * @param {HTMLElement} element Any element in the DOM. */ function getRowIndex(element) { let row = element.closest('tr'); return parseInt(row.getAttribute('data-row-index'), 10); } /** * Creates an empty tab discards table row with action-link listeners, etc. * By default the links are inactive. */ function createEmptyTabDiscardsInfoTableRow() { let template = $('tab-discard-info-row'); let content = document.importNode(template.content, true); let row = content.querySelector('tr'); // Set up the listener for the auto-discardable toggle action. let isAutoDiscardable = row.querySelector('.is-auto-discardable-link'); isAutoDiscardable.setAttribute('disabled', ''); isAutoDiscardable.addEventListener('click', (e) => { // Get the info backing this row. let info = infos[getRowIndex(e.target)]; // Disable the action. The update function is responsible for // re-enabling actions if necessary. e.target.setAttribute('disabled', ''); // Perform the action. uiHandler.setAutoDiscardable(info.id, !info.isAutoDiscardable) .then(stableUpdateTabDiscardsInfoTable()); }); // Set up the listeners for discard links. let discardListener = function(e) { // Get the info backing this row. let info = infos[getRowIndex(e.target)]; // Determine whether this is urgent or not. let urgent = e.target.classList.contains('discard-urgent-link'); // Disable the action. The update function is responsible for // re-enabling actions if necessary. e.target.setAttribute('disabled', ''); // Perform the action. uiHandler.discardById(info.id, urgent).then((response) => { stableUpdateTabDiscardsInfoTable(); }); }; let discardLink = row.querySelector('.discard-link'); let discardUrgentLink = row.querySelector('.discard-urgent-link'); discardLink.addEventListener('click', discardListener); discardUrgentLink.addEventListener('click', discardListener); return row; } /** * Updates a tab discards info table row in place. Sets/unsets 'disabled' * attributes on action-links as necessary, and populates all contents. */ function updateTabDiscardsInfoTableRow(row, info) { // Update the content. row.querySelector('.utility-rank-cell').textContent = info.utilityRank.toString(); row.querySelector('.favicon').src = info.faviconUrl ? info.faviconUrl : 'chrome://favicon'; row.querySelector('.title-div').textContent = info.title; row.querySelector('.tab-url-cell').textContent = info.tabUrl; row.querySelector('.is-app-cell').textContent = boolToString(info.isApp); row.querySelector('.is-internal-cell').textContent = boolToString(info.isInternal); row.querySelector('.is-media-cell').textContent = boolToString(info.isMedia); row.querySelector('.is-pinned-cell').textContent = boolToString(info.isPinned); row.querySelector('.is-discarded-cell').textContent = boolToString(info.isDiscarded); row.querySelector('.discard-count-cell').textContent = info.discardCount.toString(); row.querySelector('.is-auto-discardable-div').textContent = boolToString(info.isAutoDiscardable); row.querySelector('.last-active-cell').textContent = lastActiveToString(info.lastActiveSeconds); // Enable/disable action links as appropriate. row.querySelector('.is-auto-discardable-link').removeAttribute('disabled'); let discardLink = row.querySelector('.discard-link'); let discardUrgentLink = row.querySelector('.discard-urgent-link'); if (info.isDiscarded) { discardLink.setAttribute('disabled', ''); discardUrgentLink.setAttribute('disabled', ''); } else { discardLink.removeAttribute('disabled'); discardUrgentLink.removeAttribute('disabled'); } } /** * Causes the discards info table to be rendered. Reuses existing table rows * in place to minimize disruption to the page. */ function renderTabDiscardsInfoTable() { ensureTabDiscardsInfoTableLength(); let rows = tabDiscardsInfoTableBody.querySelectorAll('tr'); for (let i = 0; i < infos.length; ++i) updateTabDiscardsInfoTableRow(rows[i], infos[i]); } /** * Causes the discard info table to be updated in as stable a manner as * possible. That is, rows will stay in their relative positions, even if the * current sort order is violated. Only the addition or removal of rows (tabs) * can cause the layout to change. */ function stableUpdateTabDiscardsInfoTableImpl() { uiHandler.getTabDiscardsInfo().then((response) => { let newInfos = response.infos; let stableInfos = []; // Update existing infos in place, remove old ones, and append new ones. // This tries to keep the existing ordering stable so that clicking links // is minimally disruptive. for (let i = 0; i < infos.length; ++i) { let oldInfo = infos[i]; let newInfo = null; for (let j = 0; j < newInfos.length; ++j) { if (newInfos[j].id == oldInfo.id) { newInfo = newInfos[j]; break; } } // Old infos that have corresponding new infos are pushed first, in the // current order of the old infos. if (newInfo != null) stableInfos.push(newInfo); } // Make sure info about new tabs is appended to the end, in the order they // were originally returned. for (let i = 0; i < newInfos.length; ++i) { let newInfo = newInfos[i]; let oldInfo = null; for (let j = 0; j < infos.length; ++j) { if (infos[j].id == newInfo.id) { oldInfo = infos[j]; break; } } // Entirely new information (has no corresponding old info) is appended // to the end. if (oldInfo == null) stableInfos.push(newInfo); } // Swap out the current info with the new stably sorted information. infos = stableInfos; // Render the content in place. renderTabDiscardsInfoTable(); }); } /** * A wrapper to stableUpdateTabDiscardsInfoTableImpl that is called due to * user action and not due to the automatic timer. Cancels the existing timer * and reschedules it after rendering instantaneously. */ function stableUpdateTabDiscardsInfoTable() { if (updateTimer) clearInterval(updateTimer); stableUpdateTabDiscardsInfoTableImpl(); updateTimer = setInterval(stableUpdateTabDiscardsInfoTableImpl, UPDATE_INTERVAL_MS); } /** * Initializes this page. Invoked by the DOMContentLoaded event. */ function initialize() { uiHandler = new mojom.DiscardsDetailsProviderPtr; Mojo.bindInterface( mojom.DiscardsDetailsProvider.name, mojo.makeRequest(uiHandler).handle); tabDiscardsInfoTableBody = $('tab-discards-info-table-body'); infos = []; sortKey = 'utilityRank'; sortReverse = false; updateTimer = null; // Set the column sort handlers. let tabDiscardsInfoTableHeader = $('tab-discards-info-table-header'); let headers = tabDiscardsInfoTableHeader.children; for (let header of headers) { header.addEventListener('click', (e) => { let newSortKey = e.target.dataset.sortKey; // Skip columns that aren't explicitly labeled with a sort-key // attribute. if (newSortKey == null) return; // Reverse the sort key if the key itself hasn't changed. if (sortKey == newSortKey) { sortReverse = !sortReverse; } else { sortKey = newSortKey; sortReverse = false; } // Undecorate the old sort column, and decorate the new one. let oldSortColumn = document.querySelector('.sort-column'); oldSortColumn.classList.remove('sort-column'); e.target.classList.add('sort-column'); if (sortReverse) e.target.setAttribute('data-sort-reverse', ''); else e.target.removeAttribute('data-sort-reverse'); sortTabDiscardsInfoTable(); renderTabDiscardsInfoTable(); }); } // Setup the "Discard a tab now" links. let discardNow = $('discard-now-link'); let discardNowUrgent = $('discard-now-urgent-link'); let discardListener = function(e) { e.target.setAttribute('disabled', ''); let urgent = e.target.id.includes('urgent'); uiHandler.discard(urgent).then(() => { stableUpdateTabDiscardsInfoTable(); e.target.removeAttribute('disabled'); }); }; discardNow.addEventListener('click', discardListener); discardNowUrgent.addEventListener('click', discardListener); stableUpdateTabDiscardsInfoTable(); } document.addEventListener('DOMContentLoaded', initialize); // These functions are exposed on the 'discards' object created by // cr.define. This allows unittesting of these functions. return { compareTabDiscardsInfos: compareTabDiscardsInfos, lastActiveToString: lastActiveToString, maybeMakePlural: maybeMakePlural }; }); // 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() { var mojomId = 'chrome/browser/ui/webui/discards/discards.mojom'; if (mojo.internal.isMojomLoaded(mojomId)) { console.warn('The following mojom is loaded multiple times: ' + mojomId); return; } mojo.internal.markMojomLoaded(mojomId); var bindings = mojo; var associatedBindings = mojo; var codec = mojo.internal; var validator = mojo.internal; var exports = mojo.internal.exposeNamespace('mojom'); function TabDiscardsInfo(values) { this.initDefaults_(); this.initFields_(values); } TabDiscardsInfo.prototype.initDefaults_ = function() { this.tabUrl = null; this.faviconUrl = null; this.title = null; this.isApp = false; this.isInternal = false; this.isMedia = false; this.isPinned = false; this.isDiscarded = false; this.isAutoDiscardable = false; this.discardCount = 0; this.utilityRank = 0; this.lastActiveSeconds = 0; this.id = 0; }; TabDiscardsInfo.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; TabDiscardsInfo.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 56} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; // validate TabDiscardsInfo.tabUrl err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 0, false) if (err !== validator.validationError.NONE) return err; // validate TabDiscardsInfo.faviconUrl err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 8, false) if (err !== validator.validationError.NONE) return err; // validate TabDiscardsInfo.title err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 16, false) if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; TabDiscardsInfo.encodedSize = codec.kStructHeaderSize + 48; TabDiscardsInfo.decode = function(decoder) { var packed; var val = new TabDiscardsInfo(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.tabUrl = decoder.decodeStruct(codec.String); val.faviconUrl = decoder.decodeStruct(codec.String); val.title = decoder.decodeStruct(codec.String); packed = decoder.readUint8(); val.isApp = (packed >> 0) & 1 ? true : false; val.isInternal = (packed >> 1) & 1 ? true : false; val.isMedia = (packed >> 2) & 1 ? true : false; val.isPinned = (packed >> 3) & 1 ? true : false; val.isDiscarded = (packed >> 4) & 1 ? true : false; val.isAutoDiscardable = (packed >> 5) & 1 ? true : false; decoder.skip(1); decoder.skip(1); decoder.skip(1); val.discardCount = decoder.decodeStruct(codec.Int32); val.utilityRank = decoder.decodeStruct(codec.Int32); val.lastActiveSeconds = decoder.decodeStruct(codec.Int32); val.id = decoder.decodeStruct(codec.Int32); decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); return val; }; TabDiscardsInfo.encode = function(encoder, val) { var packed; encoder.writeUint32(TabDiscardsInfo.encodedSize); encoder.writeUint32(0); encoder.encodeStruct(codec.String, val.tabUrl); encoder.encodeStruct(codec.String, val.faviconUrl); encoder.encodeStruct(codec.String, val.title); packed = 0; packed |= (val.isApp & 1) << 0 packed |= (val.isInternal & 1) << 1 packed |= (val.isMedia & 1) << 2 packed |= (val.isPinned & 1) << 3 packed |= (val.isDiscarded & 1) << 4 packed |= (val.isAutoDiscardable & 1) << 5 encoder.writeUint8(packed); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.encodeStruct(codec.Int32, val.discardCount); encoder.encodeStruct(codec.Int32, val.utilityRank); encoder.encodeStruct(codec.Int32, val.lastActiveSeconds); encoder.encodeStruct(codec.Int32, val.id); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); }; function DiscardsDetailsProvider_GetTabDiscardsInfo_Params(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_GetTabDiscardsInfo_Params.prototype.initDefaults_ = function() { }; DiscardsDetailsProvider_GetTabDiscardsInfo_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_GetTabDiscardsInfo_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_GetTabDiscardsInfo_Params.encodedSize = codec.kStructHeaderSize + 0; DiscardsDetailsProvider_GetTabDiscardsInfo_Params.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_GetTabDiscardsInfo_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; DiscardsDetailsProvider_GetTabDiscardsInfo_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_GetTabDiscardsInfo_Params.encodedSize); encoder.writeUint32(0); }; function DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.prototype.initDefaults_ = function() { this.infos = null; }; DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 16} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; // validate DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.infos err = messageValidator.validateArrayPointer(offset + codec.kStructHeaderSize + 0, 8, new codec.PointerTo(TabDiscardsInfo), false, [0], 0); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.encodedSize = codec.kStructHeaderSize + 8; DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.infos = decoder.decodeArrayPointer(new codec.PointerTo(TabDiscardsInfo)); return val; }; DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.encodedSize); encoder.writeUint32(0); encoder.encodeArrayPointer(new codec.PointerTo(TabDiscardsInfo), val.infos); }; function DiscardsDetailsProvider_SetAutoDiscardable_Params(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_SetAutoDiscardable_Params.prototype.initDefaults_ = function() { this.tabId = 0; this.isAutoDiscardable = false; }; DiscardsDetailsProvider_SetAutoDiscardable_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_SetAutoDiscardable_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 16} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_SetAutoDiscardable_Params.encodedSize = codec.kStructHeaderSize + 8; DiscardsDetailsProvider_SetAutoDiscardable_Params.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_SetAutoDiscardable_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.tabId = decoder.decodeStruct(codec.Int32); packed = decoder.readUint8(); val.isAutoDiscardable = (packed >> 0) & 1 ? true : false; decoder.skip(1); decoder.skip(1); decoder.skip(1); return val; }; DiscardsDetailsProvider_SetAutoDiscardable_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_SetAutoDiscardable_Params.encodedSize); encoder.writeUint32(0); encoder.encodeStruct(codec.Int32, val.tabId); packed = 0; packed |= (val.isAutoDiscardable & 1) << 0 encoder.writeUint8(packed); encoder.skip(1); encoder.skip(1); encoder.skip(1); }; function DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.prototype.initDefaults_ = function() { }; DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.encodedSize = codec.kStructHeaderSize + 0; DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.encodedSize); encoder.writeUint32(0); }; function DiscardsDetailsProvider_DiscardById_Params(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_DiscardById_Params.prototype.initDefaults_ = function() { this.tabId = 0; this.urgent = false; }; DiscardsDetailsProvider_DiscardById_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_DiscardById_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 16} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_DiscardById_Params.encodedSize = codec.kStructHeaderSize + 8; DiscardsDetailsProvider_DiscardById_Params.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_DiscardById_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.tabId = decoder.decodeStruct(codec.Int32); packed = decoder.readUint8(); val.urgent = (packed >> 0) & 1 ? true : false; decoder.skip(1); decoder.skip(1); decoder.skip(1); return val; }; DiscardsDetailsProvider_DiscardById_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_DiscardById_Params.encodedSize); encoder.writeUint32(0); encoder.encodeStruct(codec.Int32, val.tabId); packed = 0; packed |= (val.urgent & 1) << 0 encoder.writeUint8(packed); encoder.skip(1); encoder.skip(1); encoder.skip(1); }; function DiscardsDetailsProvider_DiscardById_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_DiscardById_ResponseParams.prototype.initDefaults_ = function() { }; DiscardsDetailsProvider_DiscardById_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_DiscardById_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_DiscardById_ResponseParams.encodedSize = codec.kStructHeaderSize + 0; DiscardsDetailsProvider_DiscardById_ResponseParams.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_DiscardById_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; DiscardsDetailsProvider_DiscardById_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_DiscardById_ResponseParams.encodedSize); encoder.writeUint32(0); }; function DiscardsDetailsProvider_Discard_Params(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_Discard_Params.prototype.initDefaults_ = function() { this.urgent = false; }; DiscardsDetailsProvider_Discard_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_Discard_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 16} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_Discard_Params.encodedSize = codec.kStructHeaderSize + 8; DiscardsDetailsProvider_Discard_Params.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_Discard_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); packed = decoder.readUint8(); val.urgent = (packed >> 0) & 1 ? true : false; decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); return val; }; DiscardsDetailsProvider_Discard_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_Discard_Params.encodedSize); encoder.writeUint32(0); packed = 0; packed |= (val.urgent & 1) << 0 encoder.writeUint8(packed); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); }; function DiscardsDetailsProvider_Discard_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } DiscardsDetailsProvider_Discard_ResponseParams.prototype.initDefaults_ = function() { }; DiscardsDetailsProvider_Discard_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; DiscardsDetailsProvider_Discard_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; DiscardsDetailsProvider_Discard_ResponseParams.encodedSize = codec.kStructHeaderSize + 0; DiscardsDetailsProvider_Discard_ResponseParams.decode = function(decoder) { var packed; var val = new DiscardsDetailsProvider_Discard_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; DiscardsDetailsProvider_Discard_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(DiscardsDetailsProvider_Discard_ResponseParams.encodedSize); encoder.writeUint32(0); }; var kDiscardsDetailsProvider_GetTabDiscardsInfo_Name = 780259561; var kDiscardsDetailsProvider_SetAutoDiscardable_Name = 23403735; var kDiscardsDetailsProvider_DiscardById_Name = 702515548; var kDiscardsDetailsProvider_Discard_Name = 1847686568; function DiscardsDetailsProviderPtr(handleOrPtrInfo) { this.ptr = new bindings.InterfacePtrController(DiscardsDetailsProvider, handleOrPtrInfo); } function DiscardsDetailsProviderAssociatedPtr(associatedInterfacePtrInfo) { this.ptr = new associatedBindings.AssociatedInterfacePtrController( DiscardsDetailsProvider, associatedInterfacePtrInfo); } DiscardsDetailsProviderAssociatedPtr.prototype = Object.create(DiscardsDetailsProviderPtr.prototype); DiscardsDetailsProviderAssociatedPtr.prototype.constructor = DiscardsDetailsProviderAssociatedPtr; function DiscardsDetailsProviderProxy(receiver) { this.receiver_ = receiver; } DiscardsDetailsProviderPtr.prototype.getTabDiscardsInfo = function() { return DiscardsDetailsProviderProxy.prototype.getTabDiscardsInfo .apply(this.ptr.getProxy(), arguments); }; DiscardsDetailsProviderProxy.prototype.getTabDiscardsInfo = function() { var params = new DiscardsDetailsProvider_GetTabDiscardsInfo_Params(); return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_GetTabDiscardsInfo_Name, codec.align(DiscardsDetailsProvider_GetTabDiscardsInfo_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(DiscardsDetailsProvider_GetTabDiscardsInfo_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; DiscardsDetailsProviderPtr.prototype.setAutoDiscardable = function() { return DiscardsDetailsProviderProxy.prototype.setAutoDiscardable .apply(this.ptr.getProxy(), arguments); }; DiscardsDetailsProviderProxy.prototype.setAutoDiscardable = function(tabId, isAutoDiscardable) { var params = new DiscardsDetailsProvider_SetAutoDiscardable_Params(); params.tabId = tabId; params.isAutoDiscardable = isAutoDiscardable; return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_SetAutoDiscardable_Name, codec.align(DiscardsDetailsProvider_SetAutoDiscardable_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(DiscardsDetailsProvider_SetAutoDiscardable_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; DiscardsDetailsProviderPtr.prototype.discardById = function() { return DiscardsDetailsProviderProxy.prototype.discardById .apply(this.ptr.getProxy(), arguments); }; DiscardsDetailsProviderProxy.prototype.discardById = function(tabId, urgent) { var params = new DiscardsDetailsProvider_DiscardById_Params(); params.tabId = tabId; params.urgent = urgent; return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_DiscardById_Name, codec.align(DiscardsDetailsProvider_DiscardById_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(DiscardsDetailsProvider_DiscardById_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(DiscardsDetailsProvider_DiscardById_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; DiscardsDetailsProviderPtr.prototype.discard = function() { return DiscardsDetailsProviderProxy.prototype.discard .apply(this.ptr.getProxy(), arguments); }; DiscardsDetailsProviderProxy.prototype.discard = function(urgent) { var params = new DiscardsDetailsProvider_Discard_Params(); params.urgent = urgent; return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_Discard_Name, codec.align(DiscardsDetailsProvider_Discard_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(DiscardsDetailsProvider_Discard_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(DiscardsDetailsProvider_Discard_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; function DiscardsDetailsProviderStub(delegate) { this.delegate_ = delegate; } DiscardsDetailsProviderStub.prototype.getTabDiscardsInfo = function() { return this.delegate_ && this.delegate_.getTabDiscardsInfo && this.delegate_.getTabDiscardsInfo(); } DiscardsDetailsProviderStub.prototype.setAutoDiscardable = function(tabId, isAutoDiscardable) { return this.delegate_ && this.delegate_.setAutoDiscardable && this.delegate_.setAutoDiscardable(tabId, isAutoDiscardable); } DiscardsDetailsProviderStub.prototype.discardById = function(tabId, urgent) { return this.delegate_ && this.delegate_.discardById && this.delegate_.discardById(tabId, urgent); } DiscardsDetailsProviderStub.prototype.discard = function(urgent) { return this.delegate_ && this.delegate_.discard && this.delegate_.discard(urgent); } DiscardsDetailsProviderStub.prototype.accept = function(message) { var reader = new codec.MessageReader(message); switch (reader.messageName) { default: return false; } }; DiscardsDetailsProviderStub.prototype.acceptWithResponder = function(message, responder) { var reader = new codec.MessageReader(message); switch (reader.messageName) { case kDiscardsDetailsProvider_GetTabDiscardsInfo_Name: var params = reader.decodeStruct(DiscardsDetailsProvider_GetTabDiscardsInfo_Params); this.getTabDiscardsInfo().then(function(response) { var responseParams = new DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams(); responseParams.infos = response.infos; var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_GetTabDiscardsInfo_Name, codec.align(DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; case kDiscardsDetailsProvider_SetAutoDiscardable_Name: var params = reader.decodeStruct(DiscardsDetailsProvider_SetAutoDiscardable_Params); this.setAutoDiscardable(params.tabId, params.isAutoDiscardable).then(function(response) { var responseParams = new DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams(); var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_SetAutoDiscardable_Name, codec.align(DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; case kDiscardsDetailsProvider_DiscardById_Name: var params = reader.decodeStruct(DiscardsDetailsProvider_DiscardById_Params); this.discardById(params.tabId, params.urgent).then(function(response) { var responseParams = new DiscardsDetailsProvider_DiscardById_ResponseParams(); var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_DiscardById_Name, codec.align(DiscardsDetailsProvider_DiscardById_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(DiscardsDetailsProvider_DiscardById_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; case kDiscardsDetailsProvider_Discard_Name: var params = reader.decodeStruct(DiscardsDetailsProvider_Discard_Params); this.discard(params.urgent).then(function(response) { var responseParams = new DiscardsDetailsProvider_Discard_ResponseParams(); var builder = new codec.MessageV1Builder( kDiscardsDetailsProvider_Discard_Name, codec.align(DiscardsDetailsProvider_Discard_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(DiscardsDetailsProvider_Discard_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; default: return false; } }; function validateDiscardsDetailsProviderRequest(messageValidator) { var message = messageValidator.message; var paramsClass = null; switch (message.getName()) { case kDiscardsDetailsProvider_GetTabDiscardsInfo_Name: if (message.expectsResponse()) paramsClass = DiscardsDetailsProvider_GetTabDiscardsInfo_Params; break; case kDiscardsDetailsProvider_SetAutoDiscardable_Name: if (message.expectsResponse()) paramsClass = DiscardsDetailsProvider_SetAutoDiscardable_Params; break; case kDiscardsDetailsProvider_DiscardById_Name: if (message.expectsResponse()) paramsClass = DiscardsDetailsProvider_DiscardById_Params; break; case kDiscardsDetailsProvider_Discard_Name: if (message.expectsResponse()) paramsClass = DiscardsDetailsProvider_Discard_Params; break; } if (paramsClass === null) return validator.validationError.NONE; return paramsClass.validate(messageValidator, messageValidator.message.getHeaderNumBytes()); } function validateDiscardsDetailsProviderResponse(messageValidator) { var message = messageValidator.message; var paramsClass = null; switch (message.getName()) { case kDiscardsDetailsProvider_GetTabDiscardsInfo_Name: if (message.isResponse()) paramsClass = DiscardsDetailsProvider_GetTabDiscardsInfo_ResponseParams; break; case kDiscardsDetailsProvider_SetAutoDiscardable_Name: if (message.isResponse()) paramsClass = DiscardsDetailsProvider_SetAutoDiscardable_ResponseParams; break; case kDiscardsDetailsProvider_DiscardById_Name: if (message.isResponse()) paramsClass = DiscardsDetailsProvider_DiscardById_ResponseParams; break; case kDiscardsDetailsProvider_Discard_Name: if (message.isResponse()) paramsClass = DiscardsDetailsProvider_Discard_ResponseParams; break; } if (paramsClass === null) return validator.validationError.NONE; return paramsClass.validate(messageValidator, messageValidator.message.getHeaderNumBytes()); } var DiscardsDetailsProvider = { name: 'mojom::DiscardsDetailsProvider', kVersion: 0, ptrClass: DiscardsDetailsProviderPtr, proxyClass: DiscardsDetailsProviderProxy, stubClass: DiscardsDetailsProviderStub, validateRequest: validateDiscardsDetailsProviderRequest, validateResponse: validateDiscardsDetailsProviderResponse, }; DiscardsDetailsProviderStub.prototype.validator = validateDiscardsDetailsProviderRequest; DiscardsDetailsProviderProxy.prototype.validator = validateDiscardsDetailsProviderResponse; exports.TabDiscardsInfo = TabDiscardsInfo; exports.DiscardsDetailsProvider = DiscardsDetailsProvider; exports.DiscardsDetailsProviderPtr = DiscardsDetailsProviderPtr; exports.DiscardsDetailsProviderAssociatedPtr = DiscardsDetailsProviderAssociatedPtr; })();
// 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. /** * This variable structure is here to document the structure that the template * expects to correctly populate the page. */ var moduleListDataFormat = { 'moduleList': [{ 'type': 'The type of module found', 'type_description': 'The type of module (string), defaults to blank for regular modules', 'status': 'The module status', 'location': 'The module path, not including filename', 'name': 'The name of the module', 'product_name': 'The name of the product the module belongs to', 'description': 'The module description', 'version': 'The module version', 'digital_signer': 'The signer of the digital certificate for the module', 'recommended_action': 'The help tips bitmask', 'possible_resolution': 'The help tips in string form', 'help_url': 'The link to the Help Center article' }] }; /** * Takes the |moduleListData| input argument which represents data about * the currently available modules and populates the html jstemplate * with that data. It expects an object structure like the above. * @param {Object} moduleListData Information about available modules. */ function renderTemplate(moduleListData) { // This is the javascript code that processes the template: var input = new JsEvalContext(moduleListData); var output = $('modulesTemplate'); jstProcess(input, output); } /** * Asks the C++ ConflictsHandler to get details about the available modules * and return detailed data about the configuration. */ function requestModuleListData() { cr.sendWithPromise('requestModuleList').then(returnModuleList); } /** * Called by the WebUI to re-populate the page with data representing the * current state of installed modules. * @param {Object} moduleListData Information about available modules. */ function returnModuleList(moduleListData) { renderTemplate(moduleListData); $('loading-message').style.visibility = 'hidden'; $('body-container').style.visibility = 'visible'; } // Get data and have it displayed upon loading. document.addEventListener('DOMContentLoaded', requestModuleListData);
$i18n{loadingMessage}
// 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. /** * Takes the |moduleListData| input argument which represents data about * the currently available modules and populates the html jstemplate * with that data. It expects an object structure like the above. * @param {Object} moduleListData Information about available modules */ function renderTemplate(moduleListData) { // This is the javascript code that processes the template: var input = new JsEvalContext(moduleListData); var output = $('flashInfoTemplate'); jstProcess(input, output); } /** * Asks the C++ FlashUIDOMHandler to get details about the Flash and return * the data in returnFlashInfo() (below). */ function requestFlashInfo() { chrome.send('requestFlashInfo'); } /** * Called by the WebUI to re-populate the page with data representing the * current state of Flash. * @param {Object} moduleListData Information about available modules. */ function returnFlashInfo(moduleListData) { $('loading-message').style.visibility = 'hidden'; $('body-container').style.visibility = 'visible'; renderTemplate(moduleListData); } // Get data and have it displayed upon loading. document.addEventListener('DOMContentLoaded', requestFlashInfo);
Loading...
/* 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. */ .key { font-weight: bold; width: 200px; } .value { margin-left: 10px; } // 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. var nacl = nacl || {}; (function() { /** * Takes the |moduleListData| input argument which represents data about * the currently available modules and populates the html jstemplate * with that data. It expects an object structure like the above. * @param {Object} moduleListData Information about available modules */ function renderTemplate(moduleListData) { // Process the template. var input = new JsEvalContext(moduleListData); var output = $('naclInfoTemplate'); jstProcess(input, output); } /** * Asks the C++ NaClUIDOMHandler to get details about the NaCl and return * the data in returnNaClInfo() (below). */ function requestNaClInfo() { chrome.send('requestNaClInfo'); } /** * Called by the WebUI to re-populate the page with data representing the * current state of NaCl. * @param {Object} moduleListData Information about available modules */ nacl.returnNaClInfo = function(moduleListData) { $('loading-message').hidden = 'hidden'; $('body-container').hidden = ''; renderTemplate(moduleListData); }; // Get data and have it displayed upon loading. document.addEventListener('DOMContentLoaded', requestNaClInfo); })(); $i18n{title}

$i18n{tableTitle}

]ms6f*#4z[{屽YN){_:w VЃ^78On<gcy7O ~x?¦{~ ;w؝8ޝ@?I1ߝgSAh6(g7PI8vA=hyroA()AˀߧAGb6zs?v!coNl68bH_Y{ܹyovH*)^t{ ˻$Sp3\3,ANN>8?%&ztsz~ףOקTHCa͌#fpNnWd _uchavci 4g ۮ1^?0%y=E~K_ {1n~-u7VQ wA@bBLnQJJp15511o޴mlL%KHP5 ϡ)T/%jbe-4rjy~os:ҒTi`Z'-ũ,-E:JK=<$*,&]1?!"\n΢# |vC1[/j…ǢX\uP,Õv`j`uL =8ve5l3nhml/A4 ,j H$d$YM}yww R4m hT7F֩1 .ԩOFkQ IS!76D3C35mV-TɐU1Xx̚U W )Ʃ+KҨ}F#Qw{om-6kɈdW3,̛ ԊT'\q芬ސz_d/m]񕾹JqZ}b*aн-luM6+U'$ڝ2+aKSGn1`͡YC.nuB.9-~F<: 6j "Hib$:Sb܉u#9;a$]~fڰL'6JС'P-R,JHB\hhie9j.w ͙иCqֱ KI`bMTw"ג°)uqrl$`55o \k‹vl׀%d%+[1LiƦmކ;V2w#q}[N,_IDg$r~r:+`sFP13|)^#3#( 6X@43O&F 7?GÒ#Xs'Y[mx$4Hs~p{09tӵi벻V8 hnM[+lL$~iiV[Zӳ"ޡ) R4J>^#^]X,,咻ѤPnܚAT-M(*ÜP.Y5KMϪ@fMrEZ[ ;m40T- 8eT kbxF:`/Ԭ:L+77 7-h.lv=]T[M{yӭ fi?w@V ZM٫7O \e+LG.AtE&%;Ɖ}8Dpv/׫4K?YKi#(rYk7 pMSh.HL4]k=bH'9;|FJBzd0Cs,bP=Tb!t:\!,kٟ~3:sh?`Z/`G3uQĪUd ߂};-t-( qp/[2(E*ޑrWP(JWn"rۖ:''DV;Ea.53F9jwN/_]i䞚haAR4?)[t;k@jWG(ָGZXt=Ot2^6<Ꝟ|NVGd,#vIKL )̕7ke*!nS`]:{LŬ-eo&Uݲ wRq"k UZ:R8s>s-鮼p}g4?Ko7|+MpUKia2U7B4涓Zz{:%tZY-ebW8WN%>&7Ɲ4V$()y)RgѼJ~4G(E2:h1j2l+"5du76&Ӭv/"]]^Lri!ӽU"iKE̅̚ 2dt/&&$4o0|q)-ͻw+1[3ط'hX!Yʆ>)wqO:cW ]>C]~ \a$DS+܂eNmO5i M[ ~R6n1.[IWyˉ SE2+4=Pu|R\vr,LR0۫E%7>{ثMV[}v:,abX(cjrRwzU Xȏj`gU,k՟t?YSDd˳Op8ǡ=!WyOvHDF`ËL;uLa%o ȍ|K a:-dטT9)5Q.ycګթ&W<ܛib6(#4M=(UN0ξ6N W?+SVi7Zu7sV݌T+gyk+,WI2*WZ!Y%T6GpyoU5Q%_6KdSac5d3fx9JKָ#KnzW` k<{WtO#I9P[$̖?E ?6ig$UHȨJ˘n(H18x7旧XT mnj2)2Ff1m`(44曡}ங1cmcSӳ2#^{"fW :lQ v:&KTK. KJC@GYaIX*fVܕ{\j9lm\[fRq&[/|7WA>ZJI!>Җg<6IꋛsRebY'sV䨧|7G6̓+wm]s8}]@!|ܫL`f%)HfVx؜m۝IlK$K6?[juZݟ[vi/^:vWy4G~2O{]5gc}54{yҙĞ`?eLWMޏ{zsCݼG&>iyp̜`[.o ^͖3mVM{~Lsmoj4gCuz 7:"]]./7V5_yhpӿ:tu{s7ꏯ:׺v1ÿ`G7PC^|o۽G=ztu V1-QR(t$t^} c:,em:!4ToHH4}Ak)co-@= %/7yԨx)?cuDtF?$z  NxIsqN+y2BFHupynw*~h>~LLFN1Ԑ%Iqoo^}ۺ~$x GF bOhke< v \vo !\GI:.@As ' p1?nnh)p^K& FX1 sԀ(?lvet1cxd0Hj#,F++p]!︞į S[nPHm4:8`cn^Ϙ@I]-Dѭ1+0ͅc{0HɁ0'Kљ t42L?ՋƾTKf.jMyĒq'Հ3Z5_39Sti7nC E^G {I'Kn-3b2өB@ri[1).&x@U\16=*b"Z4slU ˴i=\f bDA~|*^ r.F$^L#Li@B94XL/gߠ+1eae2"sE\;Z/T--PCcBd̯֪k?im|N 8p K[U N{>`Vڤ 6"JwU32ׄ) [| 6Ww12M#b_#Yf262z bNY|zL.Iw^"Ħ'8 : 4%ţm\ q ?^({T2B]w՜BKEH)cepJ8LZ>cëCS`]hۂ(hv;:zXБ~g9||Omrh!%[uY4cSe'% zDpUANÕ$$nCkCkߕYx{X2]ؑ!.Lc+1NA\ Ԋ x`en&&NY;qp<6䋅iT(iw4d^@ (]pJ)EywC Ŷ vs3myl2KڻNw>&RWxf*jp2$&mv5*h闍K4B9ը1\CѡJXUYMyl/`p)ŒdB*q ;<+5*E%/J9:TrXٕ wa[QK%G˰5! R1Kɲ<<=›`heu Ճbjys?nRFZ!SNE_=Ǡ)i\]`nV9"srUCCx|! ڠ76~Ӵ=iQ,"!yYvER2 kȐ(iAh9%%d?3GTN戳 [9s{e WEd9FU3-1 RcpgY0YZ*X=`NHz*asKvat(5b(9M61Md~l7ŇW1eL0^J&,o7E$%ƘQ ?I49ue&2dԪ|U44t z K2o^ դEWSIN yn[NnxGɢ ƐG?#*g7Du\W#^s{JTvL,n!UElu[ݮMFwwG0puk:7_pJ guoZSW]h3Η9|}hbu GpDb=އ]0`2!!58 ct9N3jMgԚ`߰2 ךcL  `eW!,4i29wldfXM}©l>^pL|2,g2D@F0_sFQkpW}p!_VN@@1!72(4(.QBR3dDSL&DFCmu, D,!q) `C0ʓ&8ǀK 4 m ˰ k|]/noMѦ&ils[!W5Gêѹ.Á= 2Yž~8ȋl LiD8yd 5{9ѽ#aR US_/}jS70k.'Ww+ZȚ`UGt#V6%V7} +qyJL|GN}_>~0Ko.'Z=+Ep-޹V؛8-u§Uwc=>UKe;2`Tء=X.MuOJ,Tצn"|+,=s9xO {ī`Գ3B4;1`J$)o+&LK o/ڒ1p1SpfXP_+] JT,?7#Ǥ#TKqqeb]^)qs[rUPѹ䊮.SV.oSOEwps%SA(M{Ԙg/y/LݕK(?D]Ɉn)HDޑBC GJOs84QHV$*<V𢑕Z0n+7)X0>JZU5Ucc6TLŶs\ NKue^8ZHwU,侘vh5v]P#5xEԧ8tJTES0is"0? ΪR-q25+.Hk"}ܬh%&)UG`!Fel^fls!@2L^Sh]9 (n 19K8 z dʹI8C2?b#g5  ֺMbTk҄c[sKE>2Z&ͯWNX^isy @X903itDő0Idъn0Rre s׼LTlT*jĮ#7WQA.-% {Q|1=hasEº<ʼ5H>+Fc9b;Q/JԪr0RT>RO)RR\v"-R1dQ4O2\Xᥧ+* _Cc/rRV,O⥖]0x9]C uQ-!'[01ʶZ> $q0ogL>&%^/`_.Ý6\Op+/26sbdz:<|'\Ʀa1 y:$^.E/Jf1`0 +ٿOA=<_S@<4>ށG{ z{&lD/F);IL4fSY;2!=ԛo0"f:gmFૣ/N h0?6(F/QEjh*4quw7z-Zc3m(Un@}>a$iUUAv ( ח-QZ#\n.1gUծ!Rң[df5m(⽻NyEYxA=Xv)w/*Z9]+N];%C&{pG[a˺"\S&Kfcܦk45/7_(SIF*@m_ɴ_X6mazeR޿_5%ޣҔc:>TO 86sv2\/ܪmyݎV+]:w@.=!Bc !+ 4sz/L jhYwc F_?qE2Bu(0$iߟlM[`Q].U"߬¦ٵydqWأ]>E9n\ˑؚ>ȾIߧ Y[o~ϯ 0(`,Er\ 63 ,u@IG7$Tb{odf )\sGXG"(&)<:@~iTQ _8'f$$g Wr &I ~W JHr$˧@#Y %SU$%I@Sʫ+qȯx/I8{./.vゐ Ȩx jTO튬hyn19Z\~/{fnN*v$_Q(2!niAXUz=H5#>8dbV)S{.m @,͊D+^#? x֛+K!4ː>2 eIJ/,S^Iyxbqzj,QmZo:EReGcdnPHkl~^d?Z/͖y•)6$iPK@9G T5@.#PUݓi/+=H#" /߆]@яKS^/ tm7PE{I[k7z.8 'IqMoMkD^#Z2 ޯL*L\ח76,uAKWjGd΄f>xq^G[#+y@":NQs^rJPfpObwlKWJ5Cu5+lqW0OE'UUg sX=B0)d8wi$ѧ@qB( Q (%D4UDdۨPgsT~;BTչ6Ud!L~*Ua笖LڇƧTjjAkUw.u{,b El;8tjW$bm}f%3ut=zٱß]I[z#7GC,ѽY{S*~($ jx-Dx/},q&{ so"cU[5|=FmKAYe" }|)uνTM&RM:Иu ` 9%"nXX*mЭܚV3e݁b]IZԾ+4̸˞ft_9DLd &(yc 7X\.s> =4 NY9Up fciu?ut#MGPgoAO _LHkq* 9%wn3XKKw0qzXwY3]{Kf@h.HF{ʦC!p@go7I Kmub|[x1 )7jɮ@UJ,2hi9V`hzO*>Xј)N1]3Gf]-AJ+B?̘j_DЌ5̬m1^_$#֎iG٩`xϧ4xl. F;$d3lkYKC#8hk)0:ufח{]k'ӷٹ 0"wO7[_~X65FiX nnJ u _m+r]7IUFs.>cplۑl-ƲcCx̉]MGX[~?FYms4_K` 9\Rڛ(Ҙs$#酒Ͷl-p/܇^"Vg]*q S^lEvRgJuVaR2I bC 'I/A2 "~*Ma'O, Q 5-yRS|:`4:8=;g=P(-`BQq"*F`%DxTqVf 08B QJ#b,£,K xp_w5Q4'Rv }G՜ ӧ^<0Q$󋳆n__2\蘒gz\)3 G}D#f`9*x?6 <{[pI^5DRxK8^ߔ]nO¯os-cH99n_{]զ*ۯUwһ G / j;9xЬQ"hkK}xLj[P_?J*E|){EP"^{(wKՍӻ|Ԝ~XYQ&Ot@NH`wV=-?= ENkbn:$\1+kThEN'>;#u^0J{xvժ𐌫lb<¤QPq[J2]nڈLF}C[Yu}EirvSt]6iNk>:Nv[pSvDXrcSDn泹7 i)_:uQ R|ąu"݌8 5`2{mۊh;ަn۱G}[>.@\ߜ:9=|7qGӴOp7.eG@>7=wl.r9D O&p/⭪5a»V9nز{O0}@B`GK5߼w 3=+5mi2e*#y}0 ^l8r*ّ6W'XNW<ߠ^u ::ҿ^` , .K=TVt:7쨜3%ZQ ^EswYVm uOPY4U{ZS( u`>!ı~ ix8|PpUh? {MR.kv:͚H'pCbgov_?7; t8:fuv$ ,wF\mciU]o0}WIVl{DUԩt{B&!ﻶZ*M{!~c$0VVUa˧_@ZEQm QYw2Ѡ`zI? €QNR!v6%fd`D(El-\RjdGOoSEKNO{p TʒQ+zW؇PT8|&ɲ*e)yiFHIja.$8>LP2>冋TIc)6\K@`.K({ͦ?2B4 Sҧ)\!-ª'>fV=&ue2M:WB:;ߚ ? i[>bj ,9pNUɷ3[r4ʆ++ŒuZ (3Gn u%P򲌝JMTGek=m4k~A("wV6{gZUv7Z0^U4q!,>}PƜ4Q\Z?5>hK+nNg Z?8Jz{5FNR%6mZ46V>j )tǖBi:P* mb0FNn4 QAkeU+_4oc.*?_Vj:y&"_8 _<_ނ?ӻ{=f?ۦ{{S}]i.?QwiMÃ?ྣ<o+]s8= 9bj2p!) r [ ,ےVwrx{H_OXSxv:NRa(41Y iK| ~PxO<U!4B4&| Jx"p71XH݋\ɞ~¶$4a)g~A dR Uz~(cbbF$L.B/. Ee/]sG.q¯~4}ؓIB#L$w5ojQǯ[T,C*ջZ H p aP=yDoKs cÖa [DdCS\:nI5hTA\h2t=ƳWo:2#?&,g2rzX/110C[W'y$&OR\?L %Bq0 0hV0Ob +RcQꀃSG(`>W {q9x){0$,,Hv§Fl !Xjf,tt\] 1)|}RØaͦEQuav2p&50R6VT!aQw  LĴ̧K!9]KM(^N}f/eX@娳D8J4M؆qq 2eau\v`ɸ@mcrg&ѠRq%+v[MI=hV&bXCo C0R<(]5~~ ܀ $*@9f"YWT'VJ$eA1ʖ!O,nIgβf FKBs9UPEW`|0Epna?=Eʪi]\mS!qv? E~J`da658ݧd1& o6?,ܼ =<_L>}r(d: mey<.> d'Tyn+5m1~1ߓ$od5,Ozo<ξ>kr7k-ϳ)gxNүon!kkġO4b{D3vXvCLZb5<홵`iSpeWXUhf]댭Q)raA/h}@NWdQ,)1@; m}U/X.Q]je8-1 IKXDWۈM~c'jV7;N lkw9~ [pz)hB{,1j| .nqYtL}rJ.R;J ʺ48ǥā!?6ٱDo(m^5bm%>B 5-Yl;imvykO*;Q\++J- Rޛ^w;C&?;#& x0A+P)_<9ˍ #a n[A _1Yh"2*4g2VhFVhN>FVӻZW8q<|3hM㽧ud\^ QW+L+s\Y}a"خTF\tWa^SwOӦ~~Vs_旵v6tl]kjVhĥ4~ٵ#]D7c/8$jo3Mٵlv P͖zu[˙'sJdIRxL\*ǧ;)J;_-= 񲀸ū o4>DP uJ20YA礐 >uEH{.UaycpYq Ғ`G0s=TPN3.|=T]hiM'&2|2.26ei桮 u,u"Wl}܊Ni ](-g~8\^ǕWxX Zi5\nJeJAT蝈Xo4O)jCQND=p}v}}, Hͬ'DiI2}o),e,X7:;w< p-B25ߺ1b=-,Q$"BhoQʬeb[#P^/,K~T1 &*[J츆K duAQ"V ņ!EJډ~Y H,;߈6,ڟ2ԏZm6{HZִ-oBWq 蒛wwwo/?Lgx@Fcn]ۭ}Sv@@gp ,뜑L71ߧR"ΞZ|a9[ P nJ "kB֍kiN8!ȴm+DPrg 4pMl>5ɼ@}d$)Ga_Ffanpu. LVK05fXK.<%)1R2نjq ч 1 c$QT-_91 2}Mey+w S6Cm-M]UjC%E1ϵE8c2'^(AS֊s̿FE+koW9gi7ŊG`纩fR"32KzfV8(2We$x^pXckP鼼#uO ̕ a 2_Wٍf]lm7[-^x{F$JQM%߇iE}ך3p~q-6ZG UWM_%juyʵܝn%De,@jJ?RNRI kv_~$2ya&x;&;OBVD?L֥YQnx[]A>N|d͇`;:}SڭVlsp # y[wUj}*G*8BOn뒻,O^$NDhT~wM Mt1p*~;z J7xrɊ}#nxiקv{htq}ǎʵNFplkԟ*S \ i0T|3غ, n(Me2n6ɩe'DqY%LQNǎhCMl)N1/ kF~l"g+~~3Neڼ| ԛaj|,ǙԵ9CH5BGa*?dI [U y">%9j,ϿRll}%!aM*EĔt|G_++KwԘOcfkLJS-{a]X$zBN<%vje.ƸwM#.UMo0 W0Ejo 4 VyPl:֦H$ Q|4Z"ߣ$*O ޽aPh5-61:ɀFzY% 4*[pFU:EHU@5C-1\?ܜ;(B$!媒pIqo!\`EI'͘I5/-ekpFPZA rϒd,*JKKt0I Q3\NWo4Ecƿn`fdiz)%2K` wN )1\I[! 46EԶ!Qe^X]V q$r JEz:Ψ.v+/A$j{gÝ1pY qE3̹і G=+Y=E{1RjkFhs4qN-`ɲ4ns*x%Lv6|>*nQ H :5;I/JU5ql,]\sj&]dMa\A juϟVuMBW Y-pCqxv]\eq<.@үqFqPo/TTXJa \ׁncmWm5{׭`Vdx3'ډ^pn36*^V w.d(_ MM{җiA_l7~Cti.^yvVKS8W4!0f氇0l [L-)Ěu$R_qX6ZrS|z7xk^4T:AF,5\ɹ-K"2CWrJ`0q}ͮ@(xL L,E\ᯛl~?%/04SɶLo i+-ʢp)%eo'$IEFJqa(1V$&$RgGǙ6Nǰ,9:%~&!*>BxeІRx_("ou\n2fHP;>sT!nIν3>oR\"<k)|>>̮C!qEP0|G[+}-PUN)| 0HnNKOdZIu9|}" :qbPdbkf-[(j*#;fcEP%'pͣ,(MiĊL mTtv4h rF+ʻq {욌P+l>}vi? lr(Fv>ٓ  076dOOqD ඼A噛̤9a,h)ylGt\8qWh~؀œSyvqber|j{=i;/#7j@֔Jھz, T Ѥc"01@٘ZN W!'*q]rT+]ԠB-:K,ڥA"Z{D% Jknu9YMw| HNb2.:$^P][cf+oN5m/\;6w)띳],R%R{Ow)&=.Thx?Reֻ/Ͽd* ɩ Xs۸_T}nNNd,IC (WI!ߘL`*{_~t-ņ.ZHE()TQ9L& VL(d"?ŎJNsX!w7J (G6N5d)%5V90>Mogb%Tem5RK;uCu %}frEWV9 2?S#*vkX”Sj8EM6)=O7,CY{ݲ(Bk_Z(b:L`AfIM&`It8]^ŅQ'êfGm?2Fvإ썯OVcIe/ˌ%~\Z;1oiY8V"ŖJ*g'X24-~"gȜJM//?PMX. ?خyޫp}u>ZO(z>z%R)Fv)Z5A+lvW/@M)(kC5Kʘ"# =N3ݶq qO‰R:B5ө.]]q O܀&&NJ5;ZZQZ}dJ;Qlr+p+B.E!; 5@pIq~.L WL7 W<W5d#~eoD)|CUwِ@"ƲCvSq0#u>WZ餑an%ۡŇlR}h@ɼ,~Q+"$}"=+>i Qɫt4D 3{x' 8Y5G#Eᢇą.H[GڵEmSn6|;Oo pɜwLlwM@ܰI[0Yi.=vCao|b|mi22ga1`DSFhwJ*3j.-0!dTi"^!T#.4Ybn}ڈ^ٰ20L2쬾v;ƻ&uIVиTZl5uӔZ8JuG״0zȒqPlإN۴m)4 a]K\O`;lB.̩XصbqSIMU4]'C`%3H^RrǢr[b?jm)W,sqQp.AFvQ}Q+?r$S)J՞IR{Q;ô/B0NI!>':^AR]v9$J G^kk$m:Y/μyf2isu*8 neTfye V a[QI9Lft#cǝ{qwu?9ĞYDSۦ[3! Xvg #&X1$T}2uOEkz/AZ-S!>=LWmo8{p z!򍢪FE Cn)+7e^+S{A՛nzbq4ohpX!HTA X1IlY%s+![H0*B$L]. +Ῑ'dH~_xeMH͉k~ տ]ρ_HT\;Fpf5ܒ"puؼqΑM2<'(EOb5AI,x%hwc0"4Q 8׀r(}R8.ɇ=y .mV=X;NJ_1/u^6f9˹>@tqtH:s u e\8 odt'.h. L`fkOUw_3WT%YjĹ$ ]X6WW3<8-OH   @Os_h^W@QϭOtf|jjI^Pry6E4E|bH@`} t/f: U ]TۤLQU%? #;/3%z@Q8H]`Ys8~_ܬ֑ӽNm.qg^:,672d3HJd{MD>:X~;Tu/Pd-X h8\d%R1ba(eK.XF\`R$Kp+̬Lϯ.Fp20>}F>&#>. olƟӋt>>F5/.'_i{ßG?-Ɓp~#t\_H<~,V\;&" Xf3G;*%\(rudd̀JvYj8ˑ}HQhU&Z*~׌y"c45C3J'  +~kΕ޻%͉ʟrF9etϧK% IIE|Kм fAFzЫ0Q( ?0+1>a6I8Y3%Cw2F`21Xi)`LOZ z)gbvNFW\Q²ث*mor1K9ʌŢ!3U°{?þ Ō4 .;#e%1UAIͭSӬbQ0mw^4; Q+8K=`9&w¢e7NEq^Pe! Xdbт4~lCY{"V73ku>0&OM9б. 7 ak,%bTkxJk>,GPzFKug`vb^1^0AlL=ǞuPTR^׻<4%6N[2yCZ憼{2Bmw^|(Cb'?U3  4QC>O;r` K[ZY&b s9qYh1DsyXwE5LScu|xy54(Lg~ja+xZ_*QbF2'}eL߼}-̍'1T89>^r,5%h쬚#%3 K\^31g>l8Qo:}/IUC\@RZ N !8wE/IY3&a@1 6=&A?N+p5舒Q8)i ={SfP=$ [nvf@ j|]xYttv h_2pXv4Oc1fJ(j>HhO"W2*ej)C״/L\L^3_*(vꛋU| ]CW:iӑL {wB>mT Aw@h":N> 7%o 3P(sW1D N,[JDHKym2F^YGi-a %GGp%8+4p<7@ZO.驙[8MB$qmk|F.-,Gzm=ݦs0x^Mؿo?^^`nƯ ;8 5Rr! UB^jQ/n tGo4pc]l:}eZ' ^#icCuIgkg=roK<]y$E֙ج_nsLgbxPJ0 [z;(;ɭLf,/6(GaH7$@AЭw-i-*crƪuN6 _ez߱\ȐZ0v"F+}-޶XL85id}MT;)\t*S?5pӽaAsFe=lzCtLj?V!ҥ\ݠyA/)]+U#'= t\ү<?\#P?̝fGvn>sCoxxT8%f=>ii\Mii!甂kI\#z$/hZ"1+hmT?woF5ͿIKmelț ?m9=^;nzJ Wo6~_q}R= HСc =,AgM $hC~I7Q$}RBW#+|*;Qv66{)!Y0hg<Ao‚յ) qhrXu E\L}hb6Bb6?؞ˆʑ׊)? {Cw7†( Wx~xkYڕseYIa2p6cԕԪpB|>x_hE(s3; Y3c&ў eH[,PQ rqV/%d7ac߼јpڄ0GMV^C۬2iw|V0Zfl_ m#Jy~Ě"~Je( 7o5 *hlU1,;i׭@趿Yehܣ ky"B[Q.j(lg09O/:rOw4ȃ[BcU/J!t7~8fbwO73@P &%c94'"iTdMT1vmDi5i~L-%ƞ6SP" y,#W7=6LNɤh+͟rτmԡX~ct+F˽u}rj'4 q$;5RՌ6+HDZ^߶U'Z˶x_v,'8{ѱSvHGh֎gjia mGVBQ8a8V;UkǨք= lEzk.tOSw't v'` Ɗ7O;&~%nor@Lo\8Q8J6Hz?ڠɑ5 BBT>02i?~Ri=Y΃nE!JI/cNyuG(|Ҍh'.)buW-ɾX&& tFWiz]MU "fYg oUj͠).ҨA`t<ۮ7#"N*YVsGv%+7x;xsiRƇ:cߜ#}ݿ*7[[s~%u]Ħ NLI7~q s $9KyA{Zoߟ.誷wrfzpoGV<١_?5]tأ/1`j0+NoplGJ̧Qeu#>9lb8q9 .j6;>SwK}4V7|4q`+>;mX䅒h֮"j֦D]THq;Z,)߅)g?huW9ɼ; 'gRn(7Sӛ!I27-o6$I)8&rȊ9sÅ=,I)a V#WNJ,dfw0]y]gO?}9lW?]w9_L/ |j9-gi'۷FgQm:tw_Or|yÇi_΀n~ӷZ3;g˱|Y/x9OfّὉ珟&ǣl8j^=|n 𛞭~||xy 3h.W7ۗⁿ`[׷ZʡrM dn#| 5,.f)_Sr[J݌%Qg <t28s}fp/O=,]iJ8@DC)ClcКol؏e>caO{9p}q{h;^K=]eCɮnn OZ#B98l,xb 38+W/|Rv?;=Uy[("x[lҔs"|' E˜CM@*$1%JTy򂭤kSH!UfEL ]).H ey "&E :z!]4\7zDtZy AQ֓ -= uAM '%ZC9׈.[q'֕E ]R!fy V 3-].{#_$f|i1Pɥr]4X)'R"2z:rAG @#)>q(F}$[6צ~+-6D+ΚfE8Tf0,IJ2i%<¬̦$p6c;P;GB|)F%$r)!Ș.`KSڐQUO3wL,pդ6^C 09B~jI8IuZ-R㲰: ZJ)7> bP V؍mk<TfחyxhݜגKkZD|C$+3Yb5-e--"Y7b!,C>X]s&+IQH-i? Lԙ@VMV,u< ve %H˼K.6l)>CZGN⳹cQpNB>]F|I4W-[!kڢ:iF;IBݶQ] DoCZ9;Jv`}{EIAZlÌD6fd#꬞cUrM9G3s~B`zl;ӷaYK)qOSbp{8,ٱcxDTa O\cm6Weo3ڷE-rFByMԫG4EEamv 5lv]J 8zO0"Pdy\'?zST U=)r9̓ҞtSTpA٦tM,OnUϙn)1]c[)to8jm\]{Ha36b/&rQɞĈ'FV6l8b)oX#B`!=yG7Nz̷m冷^˿wC}wnu,x/jbnRA3_IG^ދBZ4cv8^-UOU@lvK5FBs_!Br$[_!14Ar|Xd<> )$W( Sv:ĻPb!UBj%$]2a=(|$.ۉT$l' Dv{#[@z`wh)(dډ\'.LNU߽z VϬ%<]ݯcF i ѝe!#yDUI _Ag`86(m:Ռ*y/{N' >st5h +.tDKXz_qԙMzWt StS_-nMSh& OܠpৌCl""cER# =8tĝ-gJg(NǏNN*Ӟcoej[. BA Xsqn HbLɿ!PWٝKy">(Sq 0:b#Mw2L&L>53Bl}-P.pvsT^v~'VX'-ox]Vz3pToZ&K%a^MV ~^C<4Jg1m @V9"O20YIu ZJr͋3ZKs8W 5=TŖ퍷2vR)DB%@9X}_Ln d"M&KMϿ%'e&W"_\/e(9cb&)qų5`<&)NPD< 9 e k%<" alz~&$!O@L/&!KȌ̓9x5^9 o Of*DavFfqεzy/ KXѥ^#b $s_.:.fhTm^sE:y7)'XRegyfڢ5G;q5>qC-dBȤ(u٩KNQJ ŒF|. ;6y#m@Y&ҠV'࠶C RֻsbA5oɯ,K.hZQnӄ^VWZ햸ìuH8dm&T!^y苨#YMIYft%+z.TrЛTg)kmS-eY osر<LLAz q Xp33-2d"%ӈin?Bd-98IȷCF0g6ԟ@Ljm$ތߟ*̷|JlsR2{^烙W90_DS(!&2 &CfjHD%<4s^-|ƪ{zIVLK0ĢڣzBuP搙W!:%S\"* ,w'ֲ+pETDt[vu#&LF.q"H:g4NC[*W\wŌ#P`_% U'/ObI =.-Ě'2iDX>iu 0; :/a ZQmL|4ڱ[^EYU&7tM٪`g-v$Ш0$8AUg4W 9ƶ}j{f-VuVHDmX_#@+W-Hh ^;wp 椣\MEmNXZZ.D_PC'D"8, pvogr=-xzq6bJ.e4fѾi0~LkU[Yhۗ8Gϥ6QL[zݝeO;I vu^0mӺT, S5AI>AҀ\H\ӛ;η*Zwm 2yӍ¢ELTyة]1u|?>z~)x5le ~`zۡʎsةn7m z~5~q{u㏞->iG=eS!lqcм$&(<_xo Cu5VVR֚Q3>eG kՋ+8, %Or~\isյ;E-ꃔ4[4hq7ҋ=r^0?ݓ_0tW.X ::*9ê3x{BK׬]d8A׮­9!ew݊+ylw}ͪҴJZ iT|vc{Zmsu͘:O^B7O)4imMၾmԝ,|IOo|RGr8gF=6!nΉf/+՝EbyEJkG:Rݶ#ҊwĆu'Ze̩Rw}4̲Oxk<âЊ'L'xB0C7.)tS׶{ *=U18hPH߸T 睺u o~u_hp?n(ZmFآԹ-(}Hpw ɱlj9Nw )w^ڢ"%2p9I<>>%ρi9J+Ip0ҍ%! ø2b`2 J%Ng`JR[a_icX~~7GKq)EC~ Ȯ-y֯!D>n ogI1@ߏ٤dKAB-kٲ@ʾ#(e&K|#IvqP]^0hʜ[WWTBD ,# x.~*IdaRN/!ZP_sc]aA 4ER~~q~fI-Pw}O+1"} 0|+WbRK$F9(,%{FZ&Wh)vo މa lw "|[Fo<.W|p=mmC`!FQ5 9(@x٭ o' CYBI =npKN->雨+ /2_]`vMqJV NT) .VҶ>Sb+K-Jg>(JYBti4idU t۟׺+Vբv1a6ZmtpٳqTyB|AR.K˷U8:x$9M"}bSx( q){0Q-MAD15lX)f1qC`59m)7,y1gQi4(x ܥWŃO.FqTwfyվc5WK].CLjat. nՊ/R=?eqR^ ,VC"t6L3fx,7-] U$e|q۸7JֳKԍj'y Zr C_M%c4`VqyG/ղ^vbBfϨ: 83+izoQM*,v<{RbZXPG1H<2"-uo爚]N߻q튧S|XF0CD,Ú^^js X;Z?TVL2MVUQlMg[¯!!X*Ԯ/g#Re%N{B|.7[3*WISm-ˠL(I\M,ة`Npj^ UjLy*׹](v ]N-i]&FSΧg ONM_˒(НŠe$EƩT]ζG4ՠ7:G5;Zh fAja\}Dq^Dem۾[WQ*Ov@Qyc]/>Tg z̭f'r:uf]ζVt z1~(ÉPF4I&&6Fz o$\ 6&Z!u]G!BZc28FӬ?*/@7o :w 5XqBh^; S5QN6C73<'VMs6Wl.TKg丵cSg3NN"W" ]?b{>}ooJW{#ׅ_~W\ \Fod.|t4W idZo(aOۗPA&,VV9HEwW7o`%KLGN Y96i/fe~nJIAriRZ{ h[T]^9SVv 6|TeMP.JF/EVWܟnEY&{/!HgXmfK qZT*Gډ8z6e&q%PɞLaUIx\@Y.5KiZtxj/vO!MBzRi]"|r#h 쫯%qFH7geXB-HhLI}4U'ӊl4/ Xg8[A9ڷt " @~sr ~p&cfC9 jyt3>~tJ:!b9<LG>mc #Y{w|W!<aah]mTf1F8 mͧZ<;TyVMc5p|=<4Y"U9Q *#T6xg=ېH ~b5mbtf 'ؼ@;XWFzRxxؑG mD߽P#Ojo#f^쁵wRyB~wҎ 2|3P/t}ZI$'~J2P"u#.u~>"%r3<Kp opuop Xo6_q}96  X32idEMݑeIǦwdS+qh_~4Qr# \:*2MxՎ`2/9rYC$c^x =0s1;>吊gȦ!b8IZ"Adxt~Z< &''8َ[ h(r y4ߌ kt/"dJ ɩ4|"d0T0ȭe21,Bf|Y>?)TgLOB  睬-:Vuxu`ԇ>k / "naEVr5,ώs?1&7Jn{؆6'&ۊC,mʊϫ<OGDD [r+\\ek Ұ?ʛ7ۻח b 3f B!}xK@7Jl1c}.A+/D XVJsqFcˎý ZԓbÎ9V^% o) 8[$:5όs9dRC*/2-fd.jTD bm)諸 7܄ aϖk%g9=clbN`UDZ*MBo[%Cw,+K*8cqe@{@M}f{5u_bKPl6ԔơW$iMh Q!F O\ʘJE˵OꓦW]eLGcͮMa]Pvj:]d!8 *{ðwB{g4i<<<? MG~7@u& O:5_OQt&L hu;vȳ)JpG"2 G/OxIK!!KwO(==NQEeGކO9$:+^CGz=h (\yʛdf+5.RE=-Zx481l%|^|EU"jTҕl9{'3Vi vzQ,&KoRwQmػ=c8|uQ4nBpP괳T3)lB2גKG3u{M55B*<55uEu2 ~EJȇ}&г޹sn6 H(l$y$(8S),85/H%Ӽ\3-B˰kDB߿|D[Ymo_1 H  w8iϵ}=Z CQ+re-BR̾qIJ͇ Z3L(wR<5|{9ܬe+.u!R rqo2C TQɄCRS2),wOJ2HxiHXKNVE r+ǭ?U'Z`/=|~һ#) ʂ6\)/Ai)Q@Sfx% WT<tHS9coYm.M4f/X_~]~}O3|z~6%Ǐ[V;F![ ~'y)GolP+rx[v|lĉ5a =x ??\B_䫢o 77w*IЁLJGi}RnRYI,J.-U"UЭN 5>h{;T%BLb{G.tœ-_nڠ 3:t˓B5ی/: )ލ䆒ὀ1<&ƀ_3 ~ؼE(=fiULAhH*vbuFFyzY:h3jQ龠Zo0-2"ֻtII|sEYEŇ-KzQvog.ﶨ\ lQ#ŃShj@iJU]Os!8nMACצ :y(6˿z>"O* >jZcO~K! $[20˾k=w Q48VPD(<7NϞ:JQ^`x}Mb6]4u;G`|kAKA޽q}p>< fuD 9 Oɟ}N3ѧpȐk4æ~m:uhu)={F t1a4qS :'N-uRP+yHS|DCteBj0A$וF 2}:EFЅ [D<3o 6\;ߗvQ-N +u̍@qcp-k¨9ŽjQ)&. vMqs{^󼓗lTꠉ(Otd#`˼m8iXdTć?zV`X\}w-%B͉F+N|\EjKJKMe04AfTF͉#Qm‘iUװƮ3QL$Lb` ( P &3X1%&߰:•dOP&žVBgWov4xQ0}$rY=!7pby^LߪX>ƶ~/]jYS! 9vtlZϚ;SunΩa hSqӂɄ{ ۴kuL>IcanEmWh;#pۜQQb+REÃ4CHoMr~s]xn5QMa$,0|BO^o<º\c;LoqsMXD{QWAZݘu4ޕhP_헽kRH4쮢T%5דl_Ɯ㓿o0[|\Vj=AEHP8IfP~M jԂ| X^Us3C+p6U`M۷7P smXAc>I5iC2/Aל5Dk |MR)A"B0!)Q;**s9Ǯ#.bt;lt]]la64^;y ?804üz:M=pD_*)ǵ'3*9lK%Q|n˵Y[oFbS"*)79Z++I\XNR 3VbBR}|Ȳ|h"<;3d2J+]yь(\R($gKI/EҜiJ',$^&V1#q$ 2tIH3d4~3(f~3xC%-Ps9i'J(r g(D^VN'~.y%2n\I 4zs8 dI9$44L7φ[wi>vV@8OȖ`0g$D4QLY55C' . $Om6/j.*mZ2@>6TPm,irxtd74dA40OH="x pUs"2*T&)*RC\ 5=c-bXQa'SYs.^eX+*`k@8 _V܅A@('"CzHZn<~!_DDciS&= }ђ V* 'b|% #( 8O!; :lWDΓ(z]sń\=&5 ,reT%$Qv]X,KfJ ]sۥT# %kbAYSQ]vZJ;i!y$}e2,>[)D0#.a Hqm8)AIn1Ct4l;Db6Hpȯo!Ǧ-^ae‡IaWђ%_)XLh%vk|аB2.zwsAwg\ nuHaJa(NBaKY,3rxH{Bna:0p]3 +=pӄfculN^ޏz=?˧4ato]V_n*lдꏫwƇ?qy'mгᗌ9O>+J2 w3~ qGlɰp-O/au1ry6W2W꡼aSkRwYp`5!_LvWŶ|P c䎠A $$s |ekYISs(͎=Vh o4n΁sS%fkq4Ժ{ƞMv ixp]ä:磿 |;q&4w53d667>tNqc(BNXlІls(YҿЏB Q(?9ᚃ3'tz#=}Q\*]n~[,bB1 cH:N%V xhe,QL)ce—t}W93.ţ[Id ''(,s2m=3ѓ.pu<*WaAa54$ׯ8fsKzB"Pl69φ SmC"8FvL8RBmF4^}zgFenW-Ő!ßEa:n3U8bx佘 V H㋓73u.ii^C'Lgם=nic`2a;s%$)t6\'? Vډ!)% n'pPr3q̀_NXr?DL _kts1bgc\ٯL\j:E)UrZBn&+QO2Ϲpz>PV]Y%rvE.EеTVO]a/*uk5!P vo mjm$iY9nt1*ڢt}X$͢8ABE9LD-8U/XYt)lGt4|5xqUyB%vu?[D\Q1b];LS2?baRq0,n0\\b*YSߵJtR\zgxa)m pGKB?oyzm:|8^n4NeNU_iJU+l^°1meQ]n+UJC[ɫ ᵇۙ>npyC)35S*KT^%=Fh9Ksu򶒸.@s!+Ҫ~Lju埍{G- '?;9#ÇXpxrxɸ¾O#\&M^녪ʷ֮21EfAڮ#ZЫ jڤ("dR<_iseO ՗[o}2-=rX4ST5B_@}&wly /aj(mRzW~Ϟ=}_{]LZ@X2mC[9R qѮ @I?9m[˚_-w6'rt=cIq2S^* HhK"rvgK=1솳sϴzµa&dnAYH˻zp'RiI$R(“5ECIe(!bKQ滘Ҹ|5= }P0)B ԩTE)&eW&5 _/⚙ˑwe[J6jիtz[}AP]w_PΈګ f_ɆrGD3@ٚ rYr޷~;Ub. ߹_C[cwmn^l s?fV,j/1Z1{ "name": "pdf_compositor", "display_name": "PDF Compositor Service", "sandbox_type": "pdf_compositor", "interface_provider_specs": { "service_manager:connector": { "provides": { "compositor": [ "printing::mojom::PdfCompositor" ] }, "requires": { "*": [ "app" ], "service_manager": [ "service_manager:all_users" ] } } } } Inspectable pages
Inspectable pages

UMo0 WF[TN/n; P$:V&KD _37:EG..tljjE~]/7/#%ig20'[wbOYD.IH,R& Fl@GF j6w ࢗrBE/V" ~yIFh[U6 30@suK|6,H|wMka)]je =EНbԱ'RfS^zf {wvsJ \qRgņ1\N"4Jhy vS"ԦN\uibyUoZȇKigA0EI5^qځ6)3Ӓp{y%Zlgp>.x9B499ЄRlU[ St%X U@]tLVy7\ǰ^=LmlZ1d;0r\yy;EK97#/"GZhxm''u$ Yz~@/$JOGqwtΖ]L>_A,$va?t>EGJ*=== z{?,ez.!C8ټ|laJ7OB(E;4z+ ڵ;T:f3)UC}k绿۠쵘DK@"+;Gw9jCYav#]VSDjfD؝9?8h8"ӓ^yiޅ[}ppԸ0 lHt~0\ "uYTenτ^̊gY" ԕ&"`tꖦ4kCt d<M ZйrnϷ+.),GANF,vz]'WU"$a-bQA%k9F:]B3htլt㟩ʇ NƩwRsN&څNQx.5\ԅk|q ,򓁄Q 3 :D,pX..s \R˙fkgUʕ1".QPN*s.fLIn,n|t47dj?C\y=ewXqC廕Sl7zT\OSL-JA.N7[db\#R@ &I2?t5U6^'mJu(Lѻmj<%.,ߩJ(ަ1SZawtG35ҽ%\[M 'ٌ>T,>6C\~Quel?FL,BMN09[ҪIӨPVt8jjGZwlj!efff9N5-"5+®$6u@!QxT[ Z \#-)Gv7RjI:gB2?(DMKϫ"{pZ t]W Dx8 gvbrOBCy̶f`JZ߈7 JW|Kg#pS;>I/ס5cX޻[< 9똨Y$D^|Ӈtаָ My8t!G 圌@D麿3%p"upcV8=E{>1WO#7~_1HpKq*\E &mR_MKy |7=H̗M~xLFS)fqB"fqg0[ rS@R߉X8>+, "'3=M4 4H(y8hrV`20gn&y!kBO A^T3}xtKBWZTwz9Gx۱OGNx#~i!, a8Ay#l^g\鄧8x,e|M!8/5''i @M *Q:ژ1q"y"YSSݗ`3JhV@Nozde6e폵1oCeWZyN"gq\1PЈWcG_Y1!r{G? cqXm E! pcrkƆOȞٙ.Ճ$U"G;)Yq۲n28W /[m@][h NOD@y?!~+wvֆ uJ,Q`^ur}@OkKv_x#AgtW,*MBI:d~Xdf߯{>;Tլ m6{캶]v]i=Ef[ZJI0vT3a%ARQ 4"#vp1;5 K՟Q,Zsw~gr8}qFS?;]^|;ߌנR+`!泲rSnT6k4++8nȷ; g^,(T5V!/օn荱պDNLG[ J?y˩H+lݫ9j6)g*K] W=T:(TY n{B淺H2+sUaٰvim~1-FՁ ͒=lW8!BFoC\b9҃.g>ܞN: W\s 'i4 GS$t:nx}-!{+&4Wb (H Bt'C*~oZ3fWynpb = j|.%[)p62sp4ʂw uNC'99%(DFo 5;fTIZ6?S <+jb\E>`J$8LE2ƨj\\7wogPU]' : TM0WLOJ=)H$bkom|@YZ)J<o潙d4"rq@r%Y^ar PiPQ1uAf`rAB%Lq'=i4js%(lɩ bB#:N3&KApI@N0]GB$IOW"(ıvhb'EmF Z|$jC \`[QwqV+S(=] +xAMC )t˱e'^bvRpv)/qg #ނa Md-On;eܽqoKe0/PNG  IHDRatEXtSoftwareAdobe ImageReadyqe<IDATxڬS 0=,!"5)0+!D"BNd;}̲,㌱rl:`Br,Q"/q76VfQdža0bw}( A'̢z8aHqhWi~%ؿ:iL,MkJJ yƊ{$JRm=" (M&8$O i;IENDB`PNG  IHDR szzsRGBIDATX Wj"Q>3hc"Q6I [+l l"XQ0$*D%E4|2qegs`=;?wfTHRMQ].;~ 2~`@kwubZd\ -(5`8!=>>X666>Nڭ-{{{1AQSC@(Ύ16H@Uu5`eE竜++:O[5>xep$~[qoooi8.'$ yo%N+mvc"/_.1㋋ G/B)^Kh<Z`GFL-0nrtrrBT>ut#p&`0ȱ^&M&n[T@m:;;xL.* 3kkkLj)|>f,ŸJǍF"w_=%yjZ/} g@/%- 8a "U MӚnV!(;27 u]ff`nbVP znOOO 6{cXX8´cs#&d@ C‚3 j:܈ O$,8ڂl{񉶻^\#Ψ/8j跌h?f󄉐ا 3v~;{WCVFѮĸ?c=Y nIENDB`PNG  IHDRw2xbKGD pHYs  tIME: vTIDATx]{U@H!Bb&<E[Q=^.:"gHxȰ!C ytewU\T&<1&k NO?#5kg|_/S]s9s.$,dfND9h{{p$j8DCD\1Deh*Zcǎ?wvvj`0Z`v/=3[D.4-jZEp#W5M{0v9ZU^i *#?X.r>WI)/r<{qRcpǙ]G=N^4 ypǑN:2J(X[~:;9Q(*+M<>[hYfNVbU8!&hof_L{nè7n2iMe;ZZZKp$s9W$)Tl\!1J)s&/.54(]rpR9mkk{S)uqR2O//kyV H^VB&:e\tme#7MM[L1lYViDD3`Mtp G뺾y%U"ZkB#-5>W5VRv-w;ֲ4,\ |t|srΝ'P|9^Vz\p8LZK:Lpki,x*T5n>n۸a9cהj{Blw-yZRcQ%ej \ǧ<9fIJ7i`01NR_sCС hv[2+𾾾n{K-;GTiNaۈB{RW'bmwww#3Gګi'4&ɝjA%/N)e!ĩභDp4H$%":&J4J]e0+#rܩ>P(k/p\R>sxlUe`cGYG*˾Az/7Edz)\T(`uJ&"jMD4b+o&}kpGp8QMfބ=ڕB rWH?xF)uE xiBt]oRJ5< o!*W_\W*udַ&4)4.S.F8ÞF{wy/{Oї3wmhhĤ$D)ne$'YUןJ&kmms-B^6Nx<뤔w92Poo!g3kO444`{{pnx~XK\|0}>KHK疦D"if`/v8pց9ܩSZ쀛L<+v^|*3n$#CCC3m\S@ccc\EwwwԩS$`P%뉈hJ8WJyӦM$ ;,XRQ Dccs@GnB!I3BjH44_0neB|k#<.pS)qfx\Q 03<@D*0 DAmg|r jcFEӴ 71s𪑓BM\_epϲ/eր\_lY5y8pњ)ϩI}e=~Ej!*|NӴ;#"]T>KVf~P)21חeb߿jI4FCIJuJ_36̜@̬Y2B,,b (Wf~^J|V`H)ue"U=ًN"҈h8Jm(k795=:oppp!B7Qonn~.zQbَF;܃oa_*xډ]b~۷lrWgggYp]79!Sڥi'ѽDtr}|;8{0T*u3gM ,X',$s[Ra=v@K?DHz{֭_] ;Eدmܷlr+9yu]y BәŜl6RFUjM2_NDg8+ccvvvƙru%|ߒ!k' a8-[4m_R dr{d (8fv+e3ŬBx.XiU$!ċB8D ׻w(XJQJ]_D):Ǘa\D8eYt)i2D,;s[p 50BkRЛ^"Ե(+ @>gĻD|,NB5<9bƌ0W hSJ3EJ B\^ݮQ?!Kr}_D ҎfxL1| }:~Sz[4jc"-6m̙dy,|<:i?6ẻ#,P xځ_wB~3MCCCB,xT@D"{7BD.6mfMӖ 3@'@:)92(5ߋ"H@ӴМFADb!x|V.5D4d!YRpގCj|AAzF,8Y˂Re?2`\[ @W)( 4_J>72~ǁHyBKFR!Aݡ0u Qikr ,)_m ?ODǔ;D߾^\^)c-(S>W&Qz}@W^p JXbӅ{C@ӧO-STJ-Ηꯑ#nf^iJG"ǕR7Wpz RE.+WDti[e-*%ơ1p-{fE:Tfε͙n?O.O1UJ]_luQoo!n +QײRʕD1FNݢ^zV,>MFN=NbYB55ɹ`Y-.k-b{>]ig1cueV\SkUE4vuWOx٧6[J)D1_k af/L.)&!vM63b^6@lEMs=K Uew`}}}#dzڇ1{*v޵g1ߒֿd( Ėe>\F !.6R&m?i-Dt V"EDwI)˼7|:@<sݡhnn~o4M[;B`Pkjj:288N}t˘VJ}>[ѯIMXl]1SR}ɐP?BdK8')/G9HyaDv=H:`ؓ|BQ4 BW!(Ȉd{Wr&ܡQ]]]nMdPz5|;T|dt vi}-˲͒)Qqj;}b'"jD"Ye׮]2P%RgcO:tuumi0%AJyg"xhN,K);he[|JJpx@ZM.f^NnooeWk,Cܮ#^3p!_kέEj&iuRO8l¾#].^" >A)bN8JѺwb=̑/Rv[(vj1 UT4O8ݱcǟgΜicui$u]oJwdص3'~nEiUD/QJ<z ]ERj]ta޼ypST&r59ǹ:p ߿5[5Z${1þ29NTfdNG˺KsphJ&wr{ DVFE<BE*UJE5MRnD& rx`#@ԲΝ;7.\09vg˪IENDB`PNG  IHDR!'bKGD pHYs  tIME. IDATxyUƿS3=,1Y PDxX{mCDaSb (S[X} "IIH2H/$=]TR}W[KۢT*1 `̯'~fw_jhjŵև+zf`̎,d2cF1#@[~`?OD+,f~i#w͚5OϞ=;'%h=P24X<=a+^ EʶWo ymmmE^viOs1މelX,_XiVM9v'|eMMMokihg хZϋ, ݕH$vS9h{zz>DD| waLZ9b"B#{m2&&+3Qs f3L\H$bP-OBѓ07Fp8c1[vxڃj Pӵ֭bm]+JMFy78'i' [Ièﳮ Y06DtO<7Ąں뺵bٖQJ=#3f\ &,x]F׼7mt%3uzOR m~r o1en b|{+3F$fRĜںi5= ED#]NOe5Hø9iK$ׯ_bbZf>uv${x@Zj|wUj#g̘q@wFeE^^.N&h})۶n~n)F^-eȶbCCjM!hwfE[rq1*p֧ R/_ܔdc3888~bT˓POb1gokk{# X_&hkH)u[ W 5/۶'Q./vxk*d+IښWugb~cw$=hk=6I( .h4z?3u9 ^ kD 3WE"Uv8EVmێg":0'֗} Ng}gSMD;D?>d]Boö/M&q니&ѽZGG%2825LN4 N/#}j®]P$YO ѦL&㻍iv)89 =k:;;ꇂ?NOb1OϬ$Xfʔ)Oj[zۻVp}8}6K r h,Vɹ |@ I3fBDUi4aOwh+X,qڵd曙ijѢE;Q P(\*:+b`}Z _K ^óڶCߩGz e=$VID"p]99G44^ϚL&1 cE+*ݎJQOOχ Øfϫe}'&"_Y7nܸ3g)֨ C O{>^D~4_$zy/^tZ!-b744?䕙y(޽ Jr{@KKDteY >`D40JFRS>э~wf% R/ u"iC+s5{P(~cfN8[,X8qi;Ӽ9̱H$b3"Vuˮ^D cg<.\hO7,˪`m̏F8gG%Uooy< QD+3oH%r*D:6ftznJFDc+ X,+Њ|Ά)S γ,+u3/-Qy1sn `3JWbb.&x>$QhW9hw huNQJi1@k}.-Q57fM$766m(A'ZuXd290uzJ_<94` 566JD8h4X5۶XAjd=v"*z艆a'h>9Fl[u/{O{{kuGk}|KKiC:*ocJ aH6 ^ ن'|p2MD"1tSL9㗟x/ö.f4$"Ux2HX3_DD6RG&mׯ}iSdݦi>^!`DtIgggX00&+dYl΍t Ɋfzg/dris*,X'=Lr&RGIJf^N-dZUi^^J4+h-Zq㾓߿]d2yK }("r06lY֯\DB,o_@pƝw[D4v_1%J223h[h``p8XrR^-?q"0,5noo,nnKDv]M6ٱX)0<alvjGG+Z6WQ&!<gՓN{ |Rj(X`LBKDW lMBJ>MDW>io\7 %7X,-Z +W\yfm{3_ED1 /pR=XK v? ED RO{8+TZ⁙ϟ3gNf6W .%4;d~veY lwwCK77 $N`o"[3m r73R拵cIDt SYDd0 O(Mr7=^Yz3&MoxMtI1KD4:3b4T0x<3_͓7usXYhmm}u8K9^-̞=;~6rz@??k \Lg34k|ʯ-B""z8UV]1ڃJ^uIjC>^Rȅep`t"zBDtt2sZDt)SLR2B[,3/GfBrm9=-ٶel>eoq%g>yF)aj8ιxEhE=UQ3kZtM^\hqi^1x>Q)u5ϟe?c?7rxDO3/pRjho288P(Tv?7<iM|cC"`+XW3/&.Ժ|qoZ8000%h'0]^{{j$UlQɑ9_OWWמPgDtCL&oGG~7Jl&Ń+wPc Cn)˲Fa\Up%3ZXa/5a[u*|d2yawч+rN*Qߞ2JR1LG~0*L&sp˲d21sÎ:Yk\\ם̿((LDqO۶j'x9 / Jny/.,Z0Z|"Qu)JkI7a#ζ,|\Ș2Tr-VRJ]̗yI:R~V)羾Ù4f^S[̏ٶ}[2XzQX Z/ 2]܁*nFgj -5O{o]m7n GeX~ 0 9wDvX^X^F"SE`/WJ 644|tf>cfd%JQIm۞`eYE(LcDdUNpRJ1<.G-:ʫ(H^0JR.d2iVddm?IJQGJ&3MTn7紶>uTja7{iFxUpxi -ZeDbí/3fX[TL&s%ֹEkz6fɒ%0W OضݙH$vz ,P*:0*ɖe%Hp^af׭[w׿eiNDwxgAf / U6mڴ>  =}Nb1OsiDTj@kjj]!~~w„ _hQAR _8>uוRgtf]na? Ïk?SjaoWx ,m0N6=k-ZcSSC(aPC}׉DaB6A]pFԩ,Z,3x#Z랞Q75k;Eƍ 娫ADP"Jvtt8vXpaq~UN`ǚ*BGGc KiI&L8 u.Tʕ+b拘 Vk0\a5drQŸD:`rXj„ >/-0 B5gΜRt:=B5iϦRQReYrU𽻂}eY#u3U! ^2l) [ K566^1X,Rdq`T1 cmۏ~ۨefusY)2̵\RFzq4+4~hM0,0!Y'WJ8;H|cHhtR<[-@]]]-R&nD"+m~WY<53f8΋ JgwR떺6yyRJ1%C3mK$2x7ǻ5ʝh4=̗lctJimO(eWpORpטQ̥H-.{mAHh`1Ѥp8K]/VGG?-ˊ9s 3aZ)LZe}}}7W޳ ~Æ 7sU!rCxR*Q ZC0F̟tW>>0%Z[?uZݬX,Q)u~6=~4iҤ.el.;hgϞ`6l!8&גQS(W`zjooֲ,qb+p˩,^* f^֮]ff4DtD!m#_}"G[j4/k\׭ ޽L&cJfdɒ# dpg`:mZu}jh4Vo5Z뇉oms-Mom \3;+#n[drZ>kƱu_>I7F0 Y.+dqU~{+~:`_ SRJ$2yeYv\[ή#BFafϒh1$ ̷eٽ#ȕfzR/{SW8>],UV`.3]-FTFo^e]7 c.{JmkooݫˆJM$ iΜ9˲r]w?f8sn59"ȐtM&u5&R2 mgpccyc^J1Kf]=Uv1RF\8 aW-d)N_WraQ8,Hug1K뺧D_U^ADVb187W=3ݫbJ]޶#],H$r7̯Q{$*Tl6{wJ1sGCLڃŁghFYfcYs| g,3ߚ+gAZ~vc yyuZ(t\*:8綁#蕮Re=1*f^ɟbq]tzW7ijsEv7nJ"ڹp%Hf̨x<'(KkMDmwցˢQQKfΜ&J>FPn\TF!#STrz{{8BLVTuU';Ik=o%OIDAT#\3fcp,em-^n`f1YQ?Qּ-X,tBx9g:3 QHbQyt:}7J4wwwO:cqrYu^X,?}.+fo*l=@-CxV4:hcsKw  2qmmmXXW{otQwwm$,מ`uɊkFDL0-+$aa6Z8ϦDT5vbi1[񴥼/QbB660lZ5`E%{ڼ}>_"D"RZ^y+N 1svT'1t:}`,[+&+*y_%Abf~³7MMM ^I` 8Eym~m۝Ϊ".LCXhthxZfB%|Rwb&1i":=31јN çTHz;dO`0LDD".HbZij<|ͺu~?H#%pCCAa4Q3ffn&D Ahui`*uoܴiӵXlm+3fuf04` =us U㦶m*9ցcثC{ҏ4550[JMtʕ7gos[mC|#zzzx3gNƶKY!ZpR :i ͆a7%pRװf/ Ԓ%Kiiieͣ.TJ=,TFdriͮ6A[MDSZu䉆a0mo؜"=a< 8BcժUGΙ3'Sh ,n"F¶/(o{ݦM~슓?to[[[V$^?UJYL]]]{BG}B&Yu3(D3ޑHd;:2Sb` EOOOQD" 9G]1p(E5U) `k+6 3O,<:AoGRb~F'op^`b)N b*z0dL[ 744NDQDbճB6eD4_fӋͪN+ [zѳgZvgggɓ%ÊTJ/Њ*weaTJ$p8<Ұ$}Д[." Ge}0E{ٲ%<UDm0 c)njjrcX:Řy(!$EM. Fyv?H)yZ'ړ' /mŔ3{Ќhp8|slv&{֓)<,fۊ B?r`̼(ݵ㿓)ڶ}J/\H|).N$f19pk *XQa#M4MsQ<743)wF2XK,ُbC|q\@?Y #6ʟ~HlLL$p/K/Zrn@`w)r_C+>/\7`X˲dKo,"3 yqht?${i\;oZ5NڗhR Ef^}#/b(zǎx" ?x8.n3^UʢRC~4 USCޱz;0"D4+G }" ~zPl>Ff26 #;wZi7?!ئ6 HԾi"AQWkVJ=V"SFڱCDNaaIζeh[' 644`ǢHk~4 uY࣓ K`f*O+*ay ~cƖxL5_geLV @ZQ亮@}{ ZQd2˥3 >Q~IKG0ᜄ޷@+*d2Jh2iu@+*_gxZ5Nk@+*V^ F**^~h}OKKxLhEJ6%O/BVT@뽧oAVT@뽂'N_EJCEic0KUZVTVh^ " "=HhEeQ:hˠp8,ЊʢIKq@_~!Њ*[ZxZ ZQ^k\oo,ZfӐ ZQY4{3?#-1,Њ$D^|Qey'ЊZi@+*Wo+ Њʦ7%<ܒ[Eɸ_hEJ? vdg טt_t_D"ZQֶ>Q2/FK麺0eDQij=෹\YA /-mb2ffbm}x~"2?ODܟNbN=VXoX mu7\>k֬wR uL&tSSS@NL mYdcثIԇl~'|KDin&y^C[-xjڵCam~ `eY{T.% ^DDMm!8`/Fk$<OZ m]F˘ppٲeqWzC<]5cƌ1 yN0]'J) mӊJՂ &M 7Fڮ`wu_B[VTRc Ø GT-flʲm{<3DD63dbiiIyIENDB`}rF໾ClgVgqv (1{&%Gou%YP@IygV7uzOzSxVQ5O~_=$W6" ok:k'6+ !Y*knH$_7rq~^l?l?UN6-yro:縡vM i߁ z۴Kۺ(|k5%)Oޮʷn{kn[RX|gXlB c.v篿rqʷ7Ւ-E]A޾OoF;V_V~(6^Ce - ‘Y;h`dV'olceEQ.dMϠo/oa}Hѿ?XֳϬ|5P'NNh¾sSj ~~Bٮ ٟ] I{9veW)z^KxɊ(ɢ+},:+;Ϛ¾ɚjsayЇد/(qzUr .,=gW7^շĎx}[./Ȯr5X=^eE-owV>Z ^:B qOy' YqQ&Jɭ@@U֤`#,ι0S3DCW&ۀѼxv>|  ^VU:$k쬮mj[Pozn֔復Tβ9K%'^ug ''Jm|P KN< kySV,oϑW\>[s dasf[܂ɡhlVO]_nsEjrC6ͶT5i͍y#f j {|OSvC15|ji#*f_Ꝩ:B(\wP2+pvEqF*٫Z4- 8YR}L8M!M.VAdž&硟z5*VzY_Y*ؔ$,S:#tfbť ƫU Nab`D8 |{RB 9IVޥQ*(!*p3#tR! 2B4smpϔHH ,}p]# (ɊtxDNe7Le0 ^Ԁ й4> S#Opj^^Ԅ7/ajU| v$Yb :xrneOv 0$sT3.Drڒժp ɜ )W!ygH'.M%oHf*wHspeHf 7cC2Y8$( ɽ̜̄Z$&Agn v 'RSWą&9I"10(*WFjOr/^9F&ĄxAbށe ]f LAH.Lͯ6 revVfV,RiAN+)H\jFJb$ntӢXC11a=U0˛{Si~ǡC⇺ِB+d?8sơ.h\V~k$cY$T)tŀqEu:L ݰ0tu̒"3pG-M|e` h;f)]F&u? \wLK#acbN2pC?*~fc&AI!@}p&&'yd.L't[#Q umwA!y䞛FoHfBtISnVq7#ݵLnd&>5$ ytƘ H2s:Ʃ9=745N}s/Ks> M"(Q &Π25[E){,-Q+]yIR@ U1 Eqw] WCb:q(y^32*Ge`ПP1nTFZùTA<"$ ɾP8 |74ĝEd&ELHt׉M\Y2ǜyҜYF#  8TāCǂ`M&}3*<1c\Aab/41eJX1/Ec);Bwg0h F +RHp(R:WuERb]Մ ՓP~6;E2`LHlj YCq;22Tdn:%w's94!%3&` L/)0|WxP1VJ>H[ҹgYҹ&nX:GRDE:AWX>wTtR?661sxFIew9L5-K ɦv)nUߥb!KJ2盇q4MJ ")1Ί _r@0|8N:Z_r"Y "te0'zc?ጎ|ǀ .*̷eyɋC٪& ,-ɷx@k "Uؠn$bREc)V hrYrp1$ʾov\Xn[mv)onlRAn5PO;Ink꾇e[H{r.eړScwYs:ثPAvSpᰭ> ˏ7"Z>a=ǐĶyͭ(#X\g{c9&Qa.@Ngu~V~a}cٮR"_ 4j/!2gQiH WI kue:Ñf#IRWbwt+% 2P]^FQte'jQ$ M)a nB'Z]#jS^&:\TgIZ ߆iryb`pfqCF 5U:[.f5~P-l׶,stH{:Dr[_WDܽ:g'Q@1fNRjSrPK gQ qbƞ;܅ N+=@Q!nB-G@8p^.$PKj?DNN*׍}fNo݊VeڵJ.L͇y";/H :` #U<X!Oj>|8QSMF P5)=j ړ:l %wlr,&[z:}u7ZO3U= By5ܴy>QCzJ66m՜VDlqiƽ?'͡C0bmjCU캻^e}B^W ||b4RKH;(ne JP穊":KQcc?yR3y8Uۻkk#RءȻkHv_S+XϰX̷^{gnlveEq짿S'ӔL~zfnuzO ФH|BJ >9dKsϔeO1@ f'WK@ t_>_.#!˦!# IJG%HSsPf4e\ދ v f3s{h!`Ǡ3#x"o$HE>Yx6{#G=O}-)N(^(l$wAّGZ?+j\%Uiu܎nd96H_~KϿ} }> oKE>{³ _0\ }XF|'ِ/Qtxi{Dl c#B)1$z(8eZ4 +$]K+=J#d Ct` Ųhg~ly.;7(>@Q@@vH=XCjIKf>+WQȚ vEkۻWõ;oWӀ'U4۝}Ɩԡ ™䪆©)[G䮡>r*U:a:P%:׫`BZm"eg`|ƈTP=ihEvBij/1 AZl!+~zov ~SDWҲ?8vM0*T/JjZ :vרШ tf6*"DІBmD6H,@sQVH@g8tAlv3p奋ݥMT]-MR r*L w2ydAZa߼~: H #m҈g{ƚڥ {jƚ)B?KZO)^w`k]٧cB[XW&i4}57қv20h-|Oq[lX#` 5J;t5؏in1/؏. m0dNjԦ"zQC{@>Oe|`-"JB#˂炔ipS&dH,F?G[:(&B-t XOFSX'xVŦNf45i;@ -bJ+4P"Ca@ ;|u ͍ݕKs0evT;Q_im`-!u=:S$HӪ3Qr ·?K P(4xKhLP#>D͠Mv45.&Lmg.!P?hK>! 6oǠafEߖ,xƞĦpv ?E{ ^_VRܪI%/~ oD%4.XtR˧7>{~tE6=;,;izRhѯS_BhC"7mȊTE˥pG0nvepvȇ4ac@m`s&LzvEa=hc>4Aæ/<5OH @4Ts-cdpNPa 9Zz!p`D* _P^Gi>1ENqc63w6KSҝ1b܁q&KBF,.桿i l$偱N>Ybw%ꇅ>U~%~@Xnh'9@:"\"s0, NBOm D=Ou@?,qS'[^QߋFu lh2k~Fwh KQ`pĤނ -n{?f|O'D:4x1 `)P8aO!v6`@8o)4ʃU4 W'69ϜMv8ҌS+ܶТ0 ه!v'Dɺ%H G z l/OpO>F(#eD=Z>iجClX<^Q@@'әǟ8 l0ݙJ>Ȣ,j %lk.rhF{ˆ\ȋwUk,`ؖ-6m-Svd$ҥ_ɰ3kcz .1]i}V^3%fC~ō3@vbC7[_Qj'8lŜ%[k̋c}'+!լrX:1loXxC%T@70د

'"5ҡl<3=-vG*eKw6YQݶ8M&M%Դ4 %`ɥ9[{s7 b/ qM-G+gMdA[zᗨ]Y(bW32gۚd[&Ɇ|SqGώ! DEr^WU᧊42[3˕Nq|B9.#O`h&wtZϱGq~Ӊ'Co$ lg8 훈FtD'L6E4="vKud: a0;|ىDmB7J8UӀ٤tX]cqS=bb$>8xPQO㔔lcO9TA=nv|aN$1fWz {~8cNc1;g0h&L8]3 V0Px &oC9?p08*)?tR9E@: @knf3 saH 8nw Cg$}2#3gmh.]ܩM%j X>)R2D-8X浕]nXMDd}W?8Np!SAKsc^@a3k}'()stTʝn8,h[?r3i7^ZO⤩+OF&yb’x 0aVu;)l O`ZC曀кX$BC hf9[X-$ p.o7h!aꢄN^V+P͇YzWMaogŢ;,6FAbEƚ`}gy;s( Q]mjS#"[nZd v<\Q=f&!>qReԵjw/&)6&38<4-#lgee"#:F"Ie[8t0!`2a `xYsI}l'20`';ϮANL?!g4gEmf)-=*PCnhµv${ɣeha3<" h} z\<$TWM:ju0Gdx76X35. D?f3)q&)nLX9$(e㔄IJL.zn8b^8Ñ&t?N'z212̉xTFwQJ {.#x5/T&QG:MsD^Bo},I߸t|bXyd4 \CSZ\DrH΁i9]܍d Q"{&& xj=N9_7=N{<( ӋD2D?74홁JMC =pky[@gmöI1:{Ր ~Y]ʗ]wmK`8tXd%1Uf5=p.ke=o_\Qc BT3'wmVܤ][r%z(ΎzmFNX˳g6\HrU956/'&X^902{*ᇿ4Sid{/W-jF-[+ʶX{eD6u] HEQ"KmX}ٌfKuc1<5LVqcnrl78d~8]*XxO i~aN-:+u^tTt\PGpª*A=6(SiOuWF e-t%KD=Of%錅+f{&[SA- *~_WNf~g3 PPWGcӣ'Ծf-(gU\<\:kˋfEȵQg߬#/_:f̃+{: L a 86Qt?d5 ɐ&^Qث`֘9I7^F3yN7\,a2v@%<,A>A$gu%X= ;bgsT~+-F<5uuЅUm'6h@oO,:f]V_>d/21|v2oH|/R>+}(F EoОȼ"5]r^a·mJQi 2 7,l.{Jҋ`o+]ܴļ8>+e#$c`4)M4~^K份<4 پe˫rG_WVvbFjAgo0rzcDR)cM.}d(J(qK~e]n~bɧߤÃ]5̙yj 7$gT7іTƦ\&\c*"Dߕ<:,݌n_Z!TՍ@aȼd]? &Ygf_)O];nqfYi<>9hteM6Gt{I] Z/o\]Y58X^-VK&+Q35*ǿKvDsSAI4꺱=/݈#D~]W og<7n,sꈱ<;+KŴT tZiD73C&3.?uT 4ap̽2^kE3F 7.J zҮ혎z"innreGϣLYp03[7bcj4|"&HHUz3-&FM; _0a (}JGta˞o5IggÏ J xhToUJzߘ/xR&D 3$2I>Р_1S?r r1GvR 4|tAq Ě|/fLfL9|Q=bޓ'PscP>eٷn$YLGbW,x (@=Y[V4^P0O,eVrp0zHq#ǡD 0;zKnܶ~(̢w؋;LJqN[w4(pÝ̠; :&IF:"%",,,jj {C%sR{ioJxjzr՗3S9S,745!4& :O9?LWlC=T4Cmd3]("23F;QTϻ;5{a٢h~ey}5@{Խ cq c㯿pmj_c0y\~S;ǑwnЉo{ iTAgI%=&9X!rp%= # lz@횸i'Vlz׽q*l*MbHRGôcUDA395c d(anU39HŐW=70>[erD<sᄫͧ\,ꃠbFB/6Q`fPb SR˱7M@J8x6 Օ& h8Ҟ2{zf!@?qjű҃1vK ^? ߰hb9`͏!vY82G~[8i@͚:7ӷ8# Dd#`f &KٍGIDjѬ$ƨ3a;P^wR,n/'[Fx[0M- [L `fQjcJ;}pKս%͍FJnkJ7hb 1kI1z*P4g]Cݑl%GnbOǐ GSNXeqef |.KWērc`Q`*361kHCxfT(0^~?0 :6*؊rIJޜcp퍣&KlxK93A/~g7՜؂X#?67۩B"X>)޹/N )"n/ݥ+:Vn?GhXpcMM._^ЩrAL4ͤPk#11Ee7[/ڇk~y #.t ʿCN<YT' A(S)ЏtD tdwXJvQ<'k~@6R-i-TPm +פE:RB6ch*q Q'̆lwix&jCCݭr@nF= 3zIs'c8`97Q{^ٕQB4[fwPˎ%D"{Yk~nB[)~:ݡ觍^WP# AlAJ!m]Zhìb f9r:Ӌz{h5hA W-:ZaG!$kut꧊ l ]d,o;<1Ơ[lQ(mP0)fa? PlSXʭxuMbWY[Ts&Eo&/fvݤN;a-dmY3k[^IdeZdKb3lʲV9i'MNd眱( BP(loݼZڣC*o]溵{*U;`՚U>P򢵸*VUGykT|^4ZYjq7[bO*[Fٴu#f:nSH[o.IhQVVAӋ LHbxWUvE{dyK.DG(-ko'귢(0@p|^i~_m]2/'y6I5~/nS"βc\gܠT.'y(1_l>mSsj>Ń-cTMgޕ<]c~٤%9'jEt&Ow> K\HBբUDErNgF iv EAՖL*?a8 6ܝpc5Ĉz۰pZ~8PSqsZDrpp`L*Qfx Fc`t*0؁B% % #4I?"sJAȢb/rx-8h&H*ܧb'% Y6_T)VIhG݈Ef=<0ʃ Āt'rqϔӪ38q 6_@?Œge00 djJS7)_.[!8.Y]]e˼LCJ$i1`z|Χ,߉2ʌT~@Hcr{琻n8Y ğ0fXݭ,޾Lv(ߔ|/C!ĮiH:GdeV<<|3 r q}X $^p궀c9ty9cva4P] /ȧrPLx"L)WՕ8ԴߨjlIE%bɱ7$$@\4RX '*NHTF+wM;&Z4n,K*,l-4ЉRhU#{r̅:M=Z!!WNjf +eb  4`R H 4N͌%pcscd%!;nw[)xM){At.T#"P;G|bb_! ۩!U8Vp TYG$U^)#0;9#&hյpJeM[IyxpfE$ icrbv6FZyRXU櫼͋lCRqaca۪a V`Z9aRq1r|E_Sށ *CMAy]g7`P:fl#߫.y19q;3ދq9OrT}mTk@ulr%'-7ӱbf3DE 2g%abhWϜʝQ6(!l0aVs|vuDjVŕ`ʨU{>z(_|YQݭ5P.)6smϠӨ%(IGHA#%R*^`EzcxaVߜo[i \gAϳѧv"#R׍Zi=7_=ZM4PK:UܘL& u5ΎH>R$ýe{E[rszLVc|o=(؇E_XrZ67Sʦݨ6+ ̛z#6m}}דC*?f$wI@τU>IԷ?KGtOH%T{IP􃩉/ڶ.zu&܆"}..3\%gl:5(o+[ עVۛT|3ӛ/}>tZ6Q;4#hKpAQ'M2'h^N&9ԨM HD05XVy=xT`fp--! ]ڗvR;"?nɿ(y %Ū[TiRCg rhG1\Bkzs}f+ZZ;ǹ1־=}UdBCլziUju 3R;TY0]3T3PHϠSH2O^4\ï`?W9?VOt* @񠱂*0e/N뼼Y@0mn ʽ^YgpykKNߤzrOcx1UTm5c%PvKx%[?7#r9pT fMQ~y ~BGNb'NDg)2Oq/٫ɢ)b % S)0>٤5ZyŖՕ$ߥh@6)R;1҂)1dYnl>-cwC]hRN{ ?V{,Tr]GW[,݂.tSvP)h`J ƩH*_l'o([H,SyMʫz^tbR@,]Һ(L/9bN>:S3xADD=@ј!=9\%U;cI|`K<]n5sSrj{:`]Ae9GvS\0fV#M}em*3vT+'38$L *рw,@^6}C5ǒh(5xU==U7Wo }5N/pj' Q3Gvj7|/h Ke X9GODӤ^'kL ܕj*+S̫W nwRM{U L_mH'J1N 넀ia!)IgNSfm zD"+JZy02v5L83(?UaeOܱ[_fJ]MurWO_ڦ:a|S[;NׂwzkEl$4/KCx-&tw-8/jQvVXi*d_K|}; P?lx;06{ 12yQO/GZʦS(%/[Ӿ.)۶{gtWֈye"ۚB F.EyJT]_𞗛{i;nU̐F=E;ЅߡE>]q[![ظ%[v Ao擶Hp~?|L>V=|H?>w`z zIVlz}Rm u՞n(Qo‘LjrSGW朏;1JUJFf,1Q7>xQvy9)Te|N1wvҏsHqjafM@mJ0w*pOQ4|8:r, 4:4t:@~";@;_L7 KiŴcA۸"{dY~%NswaHۛǹ>YK8oFpt"JM>]Vm yheb2.e54P TT2naTh#EQѦlS2c}?]>x? Yu^XjgɹrX4NA,S&*=={y}'dp.>4gɀTa205j<8c<JL"$!OaQ[A| v@4:T5i YrɛNWлbc<|F,G_n 4*PUyL'%h F/x\mS4*+nT0"9!roU?3ʏF\3(?Xm+`m4E9}m矊[̨GIk̻{PR!+dBii0\Z$4UUA$Q9Ah=~ .΂'J='XMW HHZU@a?ο^qO-ۨv CM dJlBO=3]7t뮩_^mIt3OٔXmL(ȇ7u/S^/K!F#m@8F0Md'AV?h]SjEs,@8M'PگC0*my7N%u:C'ui6~xO8K'd4G>ngJi8_Gk:ZXM%V33kmE]QlSQd$>K71Ŀܔn?41>I1X%ٗ7Ը,7Y :rE~%ɗUBOQq'_|<_>~1xq1>τ1,}pb"2hpjQ_yTQߩO[mn7A'%\ "k2V-jjTo o\~ XE"ZO@ezmw"m9..|^􍍶^Q'P7T7wl7žx_Ny`^NwwvvՖ6Ws7 eHSfbAk^~Do:ޡͫ>mi92 >c].a-j]T':h %8izd:= h_/0*_Ld|I̾pD47:٤pm5eUy3QbNPH8 JXq9}MQQIe5P1*TW٨7C-emF^1W^l&+,Vf $w p={B[0ޒ2hzduxj>z͏jˉ?A";W;/SO{)kHhi;|@C&i0 ~?b1m?*OM?<_Նfi!J~o~R (D"R NІ[n0Ʊy(>Ͷӓm ;{ttl:l>/o9޾JNϳy3T&m[i /B ,&\=@;xZsKÃ1+& *m(nb9}l?.ax4 q ->ɢM"ukYep`H{j)>۝-7T93]L41M+S,>tB/Q7pTlw/5ѥi-8:v;@wttďoTҟzbP;;O?HN: TDj[v?'ҧ O9&R˻P=|A(?Iʔ!,%%<-Xk Pϝ7?o6h^! 4PgrB*L8MS\^/ _P= i 4۞U4/1.EV 8:dK?mT@uضW6ڭ#W V۩E Po0!ZJg5hJouTPU`7 z~k$egGęJ dҕ35RS8kFpeFZ:[%d8*'-@B!\;:ٴ_ѣ #_P,q:c| C sٺqcKSÄIaحVVV;[aEFeEnU΅˧SA?pkRΟBZ`/z[;%mq9}~Z9!O,Op-`ΞVb7}DF5F[@ Cw4*.V:wZJNdrT[aȜIBQ z$^3- qXB]>MN]Cfd5X䵔6(^] FL0"1<=ZXkS#4x- wDJm<*Z YϘ`JkYKTΉLdB%uv#̋|n)-Uiׁk؁i*m `؊%W|cv풂ur'pOH)./5%U\7]%J(H^Xeu EJl LKZbSiXѡDt%$R.bfN$KX"nظcTK{7&'}4!˦i}"F+j#zPt&ZNef EnDud}߫4ܘiA%(.P)a$j#x/qL\}=G M,qUe@EY2QN0y 2/Y3fzxqeVA+=;5ErkX3g^~ÔKA߂&KcWI1+to$7BorP RXQ4"6\g@!7bPMꀄ'C }5ʕ| s bOТ 4&C,*ZxH*U6ZCi j;~io/=g ~{!nǝX]@Ó{?߼9x6703b_~xs2|{o{ޝ?P^F[;=Oc>8;UYJy?;옲w2<}yS0 @ڽ o49g GR|ht{= ^@2݆"ᢀU>n(7$bBvlYu;'bՊrr_0qZZT %ܱKhxAB'1t1FNݞn8/0^k.dvZ?ɯtiMV'NuwNu3Gq1~ B,J Ԧ"5r}HwԪr>W!a%t{SfjR"H'|qg>ič4+% f?-dRO]'gȲ7:W_ C0HE8GK 8t?jF6T o$C\G<}>]H>"ßRb, Q70/lG#qSP᎑`:a (aA<@p=0]#+_Y6CPpAժ'(W}ABVt 4çyzW){-ʛUױ Bꆫd7N 'B5TOnfcHE5qۋ#̫Tm%:SM/z)n4RMm-YtFN磓7^|gҎ=* V1!mQxNҜn+)q)g*8,Ԥnj~æ2,%)U|#٫;APMMV&L{ޣmsV`yS+7ՂB'ֶ WvPҀ6(iH)_U0rޮX{5<8ȄYtRcX="Q60"Dui97BgkhZnS\ T*]5g$ko|^D׊$ۊ 2ULT{MRTO9 X\F(V۹nr$7pbOm 5c4B[4@/?f4D+ϊpSq jRI8̘zr9n(m QA9c*1$cKTޗ eU^#Vӓy ÃSYC:h[&:;Ӻ @;uD7`EWv_h`߀N0FXÃkg$: 千m} PY~j@`qۋO_Wy%E+pe<uO~8؉"qMc.p썷%;VL&i੡+i0t\J[k'ZzSll޻X+ XFhXx!b~0h@)-*9"m",m}3Cz֦ iqb1ʅĨ|‰Of[:t* ~5i~x5`%y@ZoX\'Ƨ{#4}n> ]\_24A*k٪A`lv9.@Jw-T"`ĪrWP M5[&u`ZڷL4V[*f( m[ZyKͦMn`G>?e>Xmy5e"xݤ!^UPL:Uacji a]d^wpALC^ *7;B0HٿTT%ۑfȞnf CPrm6_~Ije-ƭNV"5q| !0/}{BwoVvVu.O`&!věQT=5XCQB ƘujoidGOÃ/YHe u2i(=wJ;Ƅ8MSL~ECdI^aH%+lO" mK{92<})=0yw̐{6v_qon:iG^8>.F>yx'7$=Y`z W YF7sda"X7#Yq_թq.#i*ǀ@ )4/8WJdZI - ǗX0+&\bv T|zxLl Z6ҕlnֱ=sD*OԭF?8Q?M׆ֶ-oc +w!}7f=qmfm@Z"яVz0)Nűi,#ijל#="'MMf^fz:ԳNP_] F Ns ?&%m P#㸘rfnLy۴ݭխ+RX0L+}e.R-FՏ^56$:*S< $_w6.A-Y&`"aeGZ&$s{yZ+K)Chj=RysrvҩjI4-Zr8b&pSoEy/8~jN,Xsa=`U*lU)֧ ͦSzʷQYrPTi.LP",  zfA3K Nfw'PCYuуjm*-WT u`tf@`Nʌ:Pe, 'r{HdGO?ks? 8;0g WP%jʛ΍'' Dm' D[=u/zf5Tv,E c~qP!^.uU 43cf<7RY8̻/C*Wۛ:*\ )̟uTd6?hA3Vz7G9.o. +ZbTG-oPrI4$qYG&nn:ay{b #͗GPtqiOׁ)AM=_| CWҍyyZMK:-ΓBz7H+f$ޙGB "9,80E1Hbc}Աߋ<C+n#鉰b<4z-eB>6&TvoK㼇BVgă~>.)ڥ_(~`{[?s_X }'b@U0NE6sDIm'>ǧ+P(<(D$a27aTq!|Z!º E ISs*xB/WKg=`Lg7 0By7`O2y"Cm)}#zM:a≙iUMI/%ºȊie*J=a)"pMq}ƍɂğSԭcs9M# z#8h0| HK "_سrД]cčV 9_y:#np8/\sNY!/kt;mKMon"=k-TeuZ%`@tOBHa]_c?7䁳%zswmRϨzqq GfAgBukطhĺeA#p`@zɒ,ko| 2Fwf;GgKA`Ңi]56+t @>kńm5yN(_2Up|V/^h>=ahrNh =Y1\LTlK 3*n Հw%{X3bنzS,Xb#axf%`R!:!qBKJoѨ\pgڐle6۾mOZС5 k|$ hssc\6ԏ7iS;6$8wCo /1J4ZY-:oᕉ)T[\e(my}z wq̠2WQ"FwQz9/o=hs{w+P#Gi1>Q^ļ5V/aj*D7sxi|RԒ-@ɺW"G#rG@Oyv@ R50^i|QN/@<%;2/?`קSfOw'W~t9Qb~_GpFfç;|wDPIQ۷l!?EBQyû?BW(-L*?x7|tcw{o $R߾п  L?:pbA;0}3aL'U(NS%R |-Nπ! 0zO*g]h)5EI|9dMejTVZޓW0ڲx$3sZ&Wee7;.24@IEqIZOq'03ʈAԧ@y,_&,-#_8yN:{=/hs~31b4oQPyI 1mYVDN$m[=MSq{ 0a.1SDpdJzzfɧ~1x}4&@s| okZ7h'9 &) N}. knpN xmeSL1KXә` )A~l_(;,0 y& r+j#djW_qYH-czWHz߶|K*kٰlі?WN'5s?8 Bȁ2(~n+ަxYjW Qe9@}2_lU/]fy#a^FQϧY&m^va壞Cu:^b0Lg0^w[З_keEFR`O#*Ft"%lݥѴ=sFw=I}*̱ +ly"#hO[w=(ӉQgjAPTL.6xc")JI_:ZPW}zAœS[ɣ t hpIF<*܀/=4BFΝ/^ r`O#ҧ!r)u[mj$ZG@؇\7pZA I nBWXuD 5HUQVMGm7/ۮH(QrSE9T P1+f;z}7JP<"4>P.6*1e"v+ArM m84+>#kj{+ǟ/)9Y}峈F[UI"i6Sy0]6NvjOp'b޾-O{6- 'P|=LQkEDɨN/4~>ߢBq9h)oOѧA/~vz2SUcsStU%.ULa#PINm#T[tzo(`k'فL@oVZ'i 闷WuTUh!єR)G@82 Vowi),j;nS {|d]{87ѕ9dbZakK]v={23I"U q "<2Mi 67s @-׺ .Jҕe1$_NtZKSw/ I\`uofQjX9I< $aCCepSY:9okjB : 42 ~s:R&%=٩3am|Eޞx+IuEg#"L zorl"~tX]{`cn39:D,zhr_W_?a EE&sҵ#1юLkG:~T䜏 BIvp^li5Tx2nj{nZQ"TUh@T)%1!9QKHPT} dw#^H*gEMs%T HJ-RtZ!a Pې#;J(YZP@ջ"tHܯH-R 5&̸H}K9l&:= =JXǨPdf-JZQ^r{%1 ܪe\R Jv] 3$DQ/NDCJѨ&#\Xrg )9 OFAc*ڵᨉTK J7 S3osw8vP?ql‡Jx;' f1UAW˩7I)eTjQwuʋZʽ/kVfVkjQq.3y6f$4!WhcAzdL{l';TGTY,PufAyIln_ףUgCTG &^nڗI:zUVk y#IpJNHz__PSV'A{Y=gBʌݝ܋^W碜'+Ը?v_|8cC#н=x7|hq;wʗn'4{0b_vkF0J%X)ԮkE4?Q9$qIӝu w;؋%Gb،6^$ƒ0CHm(wS@jfkX])B(R7xYr|DWCqlW:t)ꎖAK?xIL iZP8!_iiPS 1.-ӂu\$֮X]>ޣǴ&b#D~/}Du4YtCzM)8. mI=C(ý3"5&-B{_,t+AurFkzzKL~YJ$σaRY\;N ^_uw|: q6.n*yWaou(zƛOeXI+TBяLҋW[p>s}.q,t1x 0.> O6yO.-U?.Rut>ר[oGǝ(nQDdAI'[l'`ߨ&H1L˶ᡗy1:4iuw/`AhI\HkXU1WkVj&T(3,Vg19WrEQ&g#̜/*<#{T,ҚB ph 9Dζ)0PA蟪I៝{p#bZTWHS\c<Ĝ6xdg9qat5SvD\uFekfVr5М7@b6-0rwCg# ݀"! XŸʊk6l9*B"Sݴ^7|iPcI"u,R6w`MCT0Jmzpu, Xx,~mq6P_Ơl鰖&3Rk\ft=¹ebF:pG@&@8rB 1C_L$&x6i1Q?'g^bAMg|IMFdkSOԌMZlt^8i{JWT9]F޾y%jkz+y3o7i=NyldS-(e Ф/Fb  -b\,+Ey@H o޾³U֟"BT5zɏ)Y$kYhsVOl7;ϊOAX4g#tff QD=!J7Дpu$ЭNokFmdra?+D~N08W}Vd! )rՔg cM-DU.CX+1H*U*uY^uiOPHS۬7Iyo} wcTG΅EX3Kǹ\ӵT%Tj֚  7:ϸ@}tɾ$-Dmjt僵) 5ɮg|#oskNh!u@0VVΡfټ_Olҭ<6Fx8,eZ}Qn %W)hq=D)bDZcV괨eڟNrPzMND;ȉoF > L¤DhҙW{$8wv*w LPu-ǝjw +FH~zME}^L$Bag薸!CjS rɚ fs0,]B#G$-;.F-vԡm`NG]Kw ^߁=1٦ =2PLjBEqo:@1I0( g@]FԊvu9''ΌI]3i"*YQ`%DZNmVKF13yk)&okJTF4a]W,?mb$l7`z8'[E ,e,RMp[C:MӰvHˀHƖIm;7t l ]aߦVﰍa1Vo[ra(*< 5qV}**y4ܞnQQ&ټ:-VSEHZTϫp+"xSnX !k2#bЦ=: >Eño0Д\Ն..;wDU)y꣍zԹ/; ]pZ֑LԲ>a._s^V~w+^*⨛ 5Fv7 b5UcɞQQmk8^-(9^y̳9IЅ\"ѥHm_gy2X:J@ 퐹BT RF3TL[OM[T5T.c$ u;S*JV=ۄ96fmdh9"Z# E@A/C/y vrJXZ*l4H̯aE]WJx/ti^ Lg)>P ng[a⃵Q^g'#ǎMcD?Qk~Sr|0V8 tT&qf:\BŬ硴*jъ<ob`ly#iMߕE쯒r&nK׷C"ps-ֲUZs[œaj] 8ifn% Rg22Gğҡ)MsCa6* \35w(d>Q ie iP/u‹3>SLőϝW(,|TY(:+r IvQ릇WjlX ,O'$ 5H%s >A @|k r6?d6fW3"}JR5SZnEgb:Z1 &(F Qwp%:.GOE4VO{-|{P@LY_hg5Ntpq ŗ|Bg/h!$$uFYQ9ݽFjݽhh'4}̬'l$U!'D/ G)Ѥry;BuދzI;@5bDG5KJϯ8#[V=SÄ#@9\,w@; ı|ܧjB(x[D yE1=Mu A eoyPoޮ7n5-q(F- FY_b_'HmQ'qa9>X\ >iLԐ@b#tAo1vNX񲨘|z:(eI3%mnwP%*j,MXH{bsxV2t 6:-6!5*IZu@, G~ (CxI*Z6Elj,˼~hݖOr*A\Ee1(p(`1|-u0"Ѣ$WY0~+ FtKeɣʕxJV'+]z~qM /60{a^w,KzЄqVw'ãIi%8Unݫ*ݱWG_dpq b Q<) Ҙlm$@ $s)AWAY  -@ %Dm"½ x*(b\$z7ܠSS(#!Mp%ElySd)9J^gBz-CE9vKG%+/?BW}#qrͷTxyp٤T_@M*W WYmrS皇VŒM2(e%ЕӷcS:Q)հm"R4YIY UtLAx֬ѶU;}Ccܽ-z _aaWsSD!xl9q:0G&?FF55>6NU7LWOHUSC:3-ޙ{VNu9~cUy}GصWpx[;%o/-ZyY;Hd# huKa@`=-:)yʺ Eo!Ј 7N4Po^)j%x 6rP7t=7[\֟zkk(%yzMĤ'wxסT_uQ Ftv&v~N&Ҷvى@fs2 SY̝fD"M>t:j@7l|߆2YTl-܌~E+Y9bY.y쵶LjN+;P?1|QtQR|An0VSls\ *h'{IjX1n#b(GYYt)kZj8WHۧ7/O{RD&|ߙN %ޖ 7$L'5U*H'Sk*)wv4;[U<U=A9֤"g-HvtV R~6P{H4d9wҀiԋ0CRpaM^wC#l!GbQ^8#*$Jc9bŤ7Ճ`1G]&q~aOxx%IT,\iնm5a`qL.ABrK*U {>RcqLorF_tm9o|.8|v`Xtӓ7Jpl&9Y/  e8~Y^[Cz*ti)"Tv8%K~\]tCWPԾ3TolHT_5Hk\ä\z{:A('1TXK|ٔ1wOTWKvXmk,|sqB8#=/G j~T\4`b+naT8t td'_V_ n8lr&D!n&jM{ۖI8.& O\ 9ks@F)}(7ÃSRBC+g v<#0f\tm&f_ӡH9&BY| J{3'29T.e#KͬG z0h4?En( Jz-FWk^k&K2zq3z|ӕГ/:j|27_ES.(}٣c㐣4Xtcx@sYٵ?$ 0&ebC0vG!_#E&m3lfjmR jI3?CW]t,F#gf<T:=qϺ [Sp ˒kcXeXb1+F oSokԐ"{H% 2+8Kpd%dx:'n&J3/x-F=+i- q]n"v_d3~rX)t],9p*+䡦дL+ZGBu9vݣ8C/c/Zb ,$?xW.hC%܉d/Qԧc09ce>7 GaCyuC#,H]Wp0؞ /jbvCFՈ=K^y} ~jp3iTg?/BK5U?}(}-qu99 sד zevRjs&A*l.B+37ƔfU6Lm׭ԭ\4,waQ+zϲ^!PXUxTVޟM7 !.*!W F!~ٮ*3Sr; ǂR!L3U"EuOvL3qgo[^a룾g8s2c2wmp)܇T  ZA$};@m!T( 4::/ŀ(iRXj p{tK>q`˻#Z1`aS>^ PRJ[Q4 ê&x(\(%oگ~]Ƀ'iP/C5Ib#qH#(\OJ'g)>dpXDH9a&u>PřnQ9N5P^yr;E ՟l3V%V2zݑ9 5XF&grc0 %VG,Z߸P.)i(dgtOhe4].$ ϡJ2c[v]b_' u aɈsJq#Mfbղ W Y>;9}ێvvt^Nƭ/\QL:W>// 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 2016 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('extensions', function() { 'use strict'; /** @enum {number} */ const Key = { Comma: 188, Del: 46, Down: 40, End: 35, Escape: 27, Home: 36, Ins: 45, Left: 37, MediaNextTrack: 176, MediaPlayPause: 179, MediaPrevTrack: 177, MediaStop: 178, PageDown: 34, PageUp: 33, Period: 190, Right: 39, Space: 32, Tab: 9, Up: 38, }; /** * Enum for whether we require modifiers of a keycode. * @enum {number} */ const ModifierPolicy = {NOT_ALLOWED: 0, REQUIRED: 1}; /** * Gets the ModifierPolicy. Currently only "MediaNextTrack", "MediaPrevTrack", * "MediaStop", "MediaPlayPause" are required to be used without any modifier. * @param {number} keyCode * @return {ModifierPolicy} */ function getModifierPolicy(keyCode) { switch (keyCode) { case Key.MediaNextTrack: case Key.MediaPlayPause: case Key.MediaPrevTrack: case Key.MediaStop: return ModifierPolicy.NOT_ALLOWED; default: return ModifierPolicy.REQUIRED; } } /** * Returns whether the keyboard event has a key modifier, which could affect * how it's handled. * @param {!KeyboardEvent} e * @param {boolean} countShiftAsModifier Whether the 'Shift' key should be * counted as modifier. * @return {boolean} True if the event has any modifiers. */ function hasModifier(e, countShiftAsModifier) { return e.ctrlKey || e.altKey || // Meta key is only relevant on Mac and CrOS, where we treat Command // and Search (respectively) as modifiers. (cr.isMac && e.metaKey) || (cr.isChromeOS && e.metaKey) || (countShiftAsModifier && e.shiftKey); } /** * Checks whether the passed in |keyCode| is a valid extension command key. * @param {number} keyCode * @return {boolean} Whether the key is valid. */ function isValidKeyCode(keyCode) { if (keyCode == Key.Escape) return false; for (let k in Key) { if (Key[k] == keyCode) return true; } return (keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) || (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0)); } /** * Converts a keystroke event to string form, ignoring invalid extension * commands. * @param {!KeyboardEvent} e * @return {string} The keystroke as a string. */ function keystrokeToString(e) { let output = []; // TODO(devlin): Should this be i18n'd? if (cr.isMac && e.metaKey) output.push('Command'); if (cr.isChromeOS && e.metaKey) output.push('Search'); if (e.ctrlKey) output.push('Ctrl'); if (!e.ctrlKey && e.altKey) output.push('Alt'); if (e.shiftKey) output.push('Shift'); let keyCode = e.keyCode; if (isValidKeyCode(keyCode)) { if ((keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) || (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0))) { output.push(String.fromCharCode(keyCode)); } else { switch (keyCode) { case Key.Comma: output.push('Comma'); break; case Key.Del: output.push('Delete'); break; case Key.Down: output.push('Down'); break; case Key.End: output.push('End'); break; case Key.Home: output.push('Home'); break; case Key.Ins: output.push('Insert'); break; case Key.Left: output.push('Left'); break; case Key.MediaNextTrack: output.push('MediaNextTrack'); break; case Key.MediaPlayPause: output.push('MediaPlayPause'); break; case Key.MediaPrevTrack: output.push('MediaPrevTrack'); break; case Key.MediaStop: output.push('MediaStop'); break; case Key.PageDown: output.push('PageDown'); break; case Key.PageUp: output.push('PageUp'); break; case Key.Period: output.push('Period'); break; case Key.Right: output.push('Right'); break; case Key.Space: output.push('Space'); break; case Key.Tab: output.push('Tab'); break; case Key.Up: output.push('Up'); break; } } } return output.join('+'); } /** * Returns true if the event has valid modifiers. * @param {!KeyboardEvent} e The keyboard event to consider. * @return {boolean} True if the event is valid. */ function hasValidModifiers(e) { switch (getModifierPolicy(e.keyCode)) { case ModifierPolicy.REQUIRED: return hasModifier(e, false); case ModifierPolicy.NOT_ALLOWED: return !hasModifier(e, true); } assertNotReached(); } return { isValidKeyCode: isValidKeyCode, keystrokeToString: keystrokeToString, hasValidModifiers: hasValidModifiers, Key: Key, }; }); cr.define('extensions', function() { 'use strict'; /** * Creates a new list of extension commands. * @param {HTMLDivElement} div * @constructor * @extends {HTMLDivElement} */ function ExtensionCommandList(div) { div.__proto__ = ExtensionCommandList.prototype; return div; } ExtensionCommandList.prototype = { __proto__: HTMLDivElement.prototype, /** * While capturing, this records the current (last) keyboard event generated * by the user. Will be |null| after capture and during capture when no * keyboard event has been generated. * @type {KeyboardEvent}. * @private */ currentKeyEvent_: null, /** * While capturing, this keeps track of the previous selection so we can * revert back to if no valid assignment is made during capture. * @type {string}. * @private */ oldValue_: '', /** * While capturing, this keeps track of which element the user asked to * change. * @type {HTMLElement}. * @private */ capturingElement_: null, /** * Updates the extensions data for the overlay. * @param {!Array} data The extension * data. */ setData: function(data) { /** @private {!Array} */ this.data_ = data; this.textContent = ''; // Iterate over the extension data and add each item to the list. this.data_.forEach(this.createNodeForExtension_.bind(this)); }, /** * Synthesizes and initializes an HTML element for the extension command * metadata given in |extension|. * @param {chrome.developerPrivate.ExtensionInfo} extension A dictionary of * extension metadata. * @private */ createNodeForExtension_: function(extension) { if (extension.commands.length == 0 || extension.state == chrome.developerPrivate.ExtensionState.DISABLED) return; var template = $('template-collection-extension-commands').querySelector( '.extension-command-list-extension-item-wrapper'); var node = template.cloneNode(true); var title = node.querySelector('.extension-title'); title.textContent = extension.name; this.appendChild(node); // Iterate over the commands data within the extension and add each item // to the list. extension.commands.forEach( this.createNodeForCommand_.bind(this, extension.id)); }, /** * Synthesizes and initializes an HTML element for the extension command * metadata given in |command|. * @param {string} extensionId The associated extension's id. * @param {chrome.developerPrivate.Command} command A dictionary of * extension command metadata. * @private */ createNodeForCommand_: function(extensionId, command) { var template = $('template-collection-extension-commands').querySelector( '.extension-command-list-command-item-wrapper'); var node = template.cloneNode(true); node.id = this.createElementId_('command', extensionId, command.name); var description = node.querySelector('.command-description'); description.textContent = command.description; var shortcutNode = node.querySelector('.command-shortcut-text'); shortcutNode.addEventListener('mouseup', this.startCapture_.bind(this)); shortcutNode.addEventListener('focus', this.handleFocus_.bind(this)); shortcutNode.addEventListener('blur', this.handleBlur_.bind(this)); shortcutNode.addEventListener('keydown', this.handleKeyDown_.bind(this)); shortcutNode.addEventListener('keyup', this.handleKeyUp_.bind(this)); if (!command.isActive) { shortcutNode.textContent = loadTimeData.getString('extensionCommandsInactive'); var commandShortcut = node.querySelector('.command-shortcut'); commandShortcut.classList.add('inactive-keybinding'); } else { shortcutNode.textContent = command.keybinding; } var commandClear = node.querySelector('.command-clear'); commandClear.id = this.createElementId_( 'clear', extensionId, command.name); commandClear.title = loadTimeData.getString('extensionCommandsDelete'); commandClear.addEventListener('click', this.handleClear_.bind(this)); var select = node.querySelector('.command-scope'); select.id = this.createElementId_( 'setCommandScope', extensionId, command.name); select.hidden = false; // Add the 'In Chrome' option. var option = document.createElement('option'); option.textContent = loadTimeData.getString('extensionCommandsRegular'); select.appendChild(option); if (command.isExtensionAction || !command.isActive) { // Extension actions cannot be global, so we might as well disable the // combo box, to signify that, and if the command is inactive, it // doesn't make sense to allow the user to adjust the scope. select.disabled = true; } else { // Add the 'Global' option. option = document.createElement('option'); option.textContent = loadTimeData.getString('extensionCommandsGlobal'); select.appendChild(option); select.selectedIndex = command.scope == chrome.developerPrivate.CommandScope.GLOBAL ? 1 : 0; select.addEventListener( 'change', this.handleSetCommandScope_.bind(this)); } this.appendChild(node); }, /** * Starts keystroke capture to determine which key to use for a particular * extension command. * @param {Event} event The keyboard event to consider. * @private */ startCapture_: function(event) { if (this.capturingElement_) return; // Already capturing. chrome.developerPrivate.setShortcutHandlingSuspended(true); var shortcutNode = event.target; this.oldValue_ = shortcutNode.textContent; shortcutNode.textContent = loadTimeData.getString('extensionCommandsStartTyping'); shortcutNode.parentElement.classList.add('capturing'); var commandClear = shortcutNode.parentElement.querySelector('.command-clear'); commandClear.hidden = true; this.capturingElement_ = /** @type {HTMLElement} */(event.target); }, /** * Ends keystroke capture and either restores the old value or (if valid * value) sets the new value as active.. * @param {Event} event The keyboard event to consider. * @private */ endCapture_: function(event) { if (!this.capturingElement_) return; // Not capturing. chrome.developerPrivate.setShortcutHandlingSuspended(false); var shortcutNode = this.capturingElement_; var commandShortcut = shortcutNode.parentElement; commandShortcut.classList.remove('capturing'); commandShortcut.classList.remove('contains-chars'); // When the capture ends, the user may have not given a complete and valid // input (or even no input at all). Only a valid key event followed by a // valid key combination will cause a shortcut selection to be activated. // If no valid selection was made, however, revert back to what the // textbox had before to indicate that the shortcut registration was // canceled. if (!this.currentKeyEvent_ || !extensions.isValidKeyCode(this.currentKeyEvent_.keyCode)) shortcutNode.textContent = this.oldValue_; var commandClear = commandShortcut.querySelector('.command-clear'); if (this.oldValue_ == '') { commandShortcut.classList.remove('clearable'); commandClear.hidden = true; } else { commandShortcut.classList.add('clearable'); commandClear.hidden = false; } this.oldValue_ = ''; this.capturingElement_ = null; this.currentKeyEvent_ = null; }, /** * Handles focus event and adds visual indication for active shortcut. * @param {Event} event to consider. * @private */ handleFocus_: function(event) { var commandShortcut = event.target.parentElement; commandShortcut.classList.add('focused'); }, /** * Handles lost focus event and removes visual indication of active shortcut * also stops capturing on focus lost. * @param {Event} event to consider. * @private */ handleBlur_: function(event) { this.endCapture_(event); var commandShortcut = event.target.parentElement; commandShortcut.classList.remove('focused'); }, /** * The KeyDown handler. * @param {Event} event The keyboard event to consider. * @private */ handleKeyDown_: function(event) { event = /** @type {KeyboardEvent} */(event); if (event.keyCode == extensions.Key.Escape) { if (!this.capturingElement_) { // If we're not currently capturing, allow escape to propagate (so it // can close the overflow). return; } // Otherwise, escape cancels capturing. this.endCapture_(event); var parsed = this.parseElementId_('clear', event.target.parentElement.querySelector('.command-clear').id); chrome.developerPrivate.updateExtensionCommand({ extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: '' }); event.preventDefault(); event.stopPropagation(); return; } if (event.keyCode == extensions.Key.Tab) { // Allow tab propagation for keyboard navigation. return; } if (!this.capturingElement_) this.startCapture_(event); this.handleKey_(event); }, /** * The KeyUp handler. * @param {Event} event The keyboard event to consider. * @private */ handleKeyUp_: function(event) { event = /** @type {KeyboardEvent} */(event); if (event.keyCode == extensions.Key.Tab || event.keyCode == extensions.Key.Escape) { // We need to allow tab propagation for keyboard navigation, and escapes // are fully handled in handleKeyDown. return; } // We want to make it easy to change from Ctrl+Shift+ to just Ctrl+ by // releasing Shift, but we also don't want it to be easy to lose for // example Ctrl+Shift+F to Ctrl+ just because you didn't release Ctrl // as fast as the other two keys. Therefore, we process KeyUp until you // have a valid combination and then stop processing it (meaning that once // you have a valid combination, we won't change it until the next // KeyDown message arrives). if (!this.currentKeyEvent_ || !extensions.isValidKeyCode(this.currentKeyEvent_.keyCode)) { if (!event.ctrlKey && !event.altKey || ((cr.isMac || cr.isChromeOS) && !event.metaKey)) { // If neither Ctrl nor Alt is pressed then it is not a valid shortcut. // That means we're back at the starting point so we should restart // capture. this.endCapture_(event); this.startCapture_(event); } else { this.handleKey_(event); } } }, /** * A general key handler (used for both KeyDown and KeyUp). * @param {KeyboardEvent} event The keyboard event to consider. * @private */ handleKey_: function(event) { // While capturing, we prevent all events from bubbling, to prevent // shortcuts lacking the right modifier (F3 for example) from activating // and ending capture prematurely. event.preventDefault(); event.stopPropagation(); if (!extensions.hasValidModifiers(event)) return; var shortcutNode = this.capturingElement_; var keystroke = extensions.keystrokeToString(event); shortcutNode.textContent = keystroke; event.target.classList.add('contains-chars'); this.currentKeyEvent_ = event; if (extensions.isValidKeyCode(event.keyCode)) { var node = event.target; while (node && !node.id) node = node.parentElement; this.oldValue_ = keystroke; // Forget what the old value was. var parsed = this.parseElementId_('command', node.id); // Ending the capture must occur before calling // setExtensionCommandShortcut to ensure the shortcut is set. this.endCapture_(event); chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: keystroke}); } }, /** * A handler for the delete command button. * @param {Event} event The mouse event to consider. * @private */ handleClear_: function(event) { var parsed = this.parseElementId_('clear', event.target.id); chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: ''}); }, /** * A handler for the setting the scope of the command. * @param {Event} event The mouse event to consider. * @private */ handleSetCommandScope_: function(event) { var parsed = this.parseElementId_('setCommandScope', event.target.id); var element = $('setCommandScope-' + parsed.extensionId + '-' + parsed.commandName); var scope = element.selectedIndex == 1 ? chrome.developerPrivate.CommandScope.GLOBAL : chrome.developerPrivate.CommandScope.CHROME; chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, scope: scope}); }, /** * A utility function to create a unique element id based on a namespace, * extension id and a command name. * @param {string} namespace The namespace to prepend the id with. * @param {string} extensionId The extension ID to use in the id. * @param {string} commandName The command name to append the id with. * @private */ createElementId_: function(namespace, extensionId, commandName) { return namespace + '-' + extensionId + '-' + commandName; }, /** * A utility function to parse a unique element id based on a namespace, * extension id and a command name. * @param {string} namespace The namespace to prepend the id with. * @param {string} id The id to parse. * @return {{extensionId: string, commandName: string}} The parsed id. * @private */ parseElementId_: function(namespace, id) { var kExtensionIdLength = 32; return { extensionId: id.substring(namespace.length + 1, namespace.length + 1 + kExtensionIdLength), commandName: id.substring(namespace.length + 1 + kExtensionIdLength + 1) }; }, }; return { ExtensionCommandList: ExtensionCommandList }; }); // 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 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('extensions', function() { 'use strict'; /** * Clone a template within the extension error template collection. * @param {string} templateName The class name of the template to clone. * @return {HTMLElement} The clone of the template. */ function cloneTemplate(templateName) { return /** @type {HTMLElement} */($('template-collection-extension-error'). querySelector('.' + templateName).cloneNode(true)); } /** * Checks that an Extension ID follows the proper format (i.e., is 32 * characters long, is lowercase, and contains letters in the range [a, p]). * @param {string} id The Extension ID to test. * @return {boolean} Whether or not the ID is valid. */ function idIsValid(id) { return /^[a-p]{32}$/.test(id); } /** * @param {!Array<(ManifestError|RuntimeError)>} errors * @param {number} id * @return {number} The index of the error with |id|, or -1 if not found. */ function findErrorById(errors, id) { for (var i = 0; i < errors.length; ++i) { if (errors[i].id == id) return i; } return -1; } /** * Creates a new ExtensionError HTMLElement; this is used to show a * notification to the user when an error is caused by an extension. * @param {(RuntimeError|ManifestError)} error The error the element should * represent. * @constructor * @extends {HTMLElement} */ function ExtensionError(error) { var div = cloneTemplate('extension-error-metadata'); div.__proto__ = ExtensionError.prototype; div.decorate(error); return div; } ExtensionError.prototype = { __proto__: HTMLElement.prototype, /** * @param {(RuntimeError|ManifestError)} error The error the element should * represent. * @private */ decorate: function(error) { /** * The backing error. * @type {(ManifestError|RuntimeError)} */ this.error = error; var iconAltTextKey = 'extensionLogLevelWarn'; // Add an additional class for the severity level. if (error.type == chrome.developerPrivate.ErrorType.RUNTIME) { switch (error.severity) { case chrome.developerPrivate.ErrorLevel.LOG: this.classList.add('extension-error-severity-info'); iconAltTextKey = 'extensionLogLevelInfo'; break; case chrome.developerPrivate.ErrorLevel.WARN: this.classList.add('extension-error-severity-warning'); break; case chrome.developerPrivate.ErrorLevel.ERROR: this.classList.add('extension-error-severity-fatal'); iconAltTextKey = 'extensionLogLevelError'; break; default: assertNotReached(); } } else { // We classify manifest errors as "warnings". this.classList.add('extension-error-severity-warning'); } var iconNode = document.createElement('img'); iconNode.className = 'extension-error-icon'; iconNode.alt = loadTimeData.getString(iconAltTextKey); this.insertBefore(iconNode, this.firstChild); var messageSpan = this.querySelector('.extension-error-message'); messageSpan.textContent = error.message; var deleteButton = this.querySelector('.error-delete-button'); deleteButton.addEventListener('click', function(e) { this.dispatchEvent( new CustomEvent('deleteExtensionError', {bubbles: true, detail: this.error})); }.bind(this)); this.addEventListener('click', function(e) { if (e.target != deleteButton) this.requestActive_(); }.bind(this)); this.addEventListener('keydown', function(e) { if (e.key == 'Enter' && e.target != deleteButton) this.requestActive_(); }); }, /** * Bubble up an event to request to become active. * @private */ requestActive_: function() { this.dispatchEvent( new CustomEvent('highlightExtensionError', {bubbles: true, detail: this.error})); }, }; /** * A variable length list of runtime or manifest errors for a given extension. * @param {Array<(RuntimeError|ManifestError)>} errors The list of extension * errors with which to populate the list. * @param {string} extensionId The id of the extension. * @constructor * @extends {HTMLDivElement} */ function ExtensionErrorList(errors, extensionId) { var div = cloneTemplate('extension-error-list'); div.__proto__ = ExtensionErrorList.prototype; div.extensionId_ = extensionId; div.decorate(errors); return div; } /** * @param {!Element} root * @param {?Element} boundary * @constructor * @extends {cr.ui.FocusRow} */ ExtensionErrorList.FocusRow = function(root, boundary) { cr.ui.FocusRow.call(this, root, boundary); this.addItem('message', '.extension-error-message'); this.addItem('delete', '.error-delete-button'); }; ExtensionErrorList.FocusRow.prototype = { __proto__: cr.ui.FocusRow.prototype, }; ExtensionErrorList.prototype = { __proto__: HTMLDivElement.prototype, /** * Initializes the extension error list. * @param {Array<(RuntimeError|ManifestError)>} errors The list of errors. */ decorate: function(errors) { /** @private {!Array<(ManifestError|RuntimeError)>} */ this.errors_ = []; /** @private {!cr.ui.FocusGrid} */ this.focusGrid_ = new cr.ui.FocusGrid(); /** @private {Element} */ this.listContents_ = this.querySelector('.extension-error-list-contents'); errors.forEach(this.addError_, this); this.focusGrid_.ensureRowActive(); this.addEventListener('highlightExtensionError', function(e) { this.setActiveErrorNode_(e.target); }); this.addEventListener('deleteExtensionError', function(e) { this.removeError_(e.detail); }); this.querySelector('#extension-error-list-clear').addEventListener( 'click', function(e) { this.clear(true); }.bind(this)); /** * The callback for the extension changed event. * @private {function(chrome.developerPrivate.EventData):void} */ this.onItemStateChangedListener_ = function(data) { var type = chrome.developerPrivate.EventType; if ((data.event_type == type.ERRORS_REMOVED || data.event_type == type.ERROR_ADDED) && data.extensionInfo.id == this.extensionId_) { var newErrors = data.extensionInfo.runtimeErrors.concat( data.extensionInfo.manifestErrors); this.updateErrors_(newErrors); } }.bind(this); chrome.developerPrivate.onItemStateChanged.addListener( this.onItemStateChangedListener_); /** * The active error element in the list. * @private {?} */ this.activeError_ = null; this.setActiveError(0); }, /** * Adds an error to the list. * @param {(RuntimeError|ManifestError)} error The error to add. * @private */ addError_: function(error) { this.querySelector('#no-errors-span').hidden = true; this.errors_.push(error); var extensionError = new ExtensionError(error); this.listContents_.appendChild(extensionError); this.focusGrid_.addRow( new ExtensionErrorList.FocusRow(extensionError, this.listContents_)); }, /** * Removes an error from the list. * @param {(RuntimeError|ManifestError)} error The error to remove. * @private */ removeError_: function(error) { var index = 0; for (; index < this.errors_.length; ++index) { if (this.errors_[index].id == error.id) break; } assert(index != this.errors_.length); var errorList = this.querySelector('.extension-error-list-contents'); var wasActive = this.activeError_ && this.activeError_.error.id == error.id; this.errors_.splice(index, 1); var listElement = errorList.children[index]; var focusRow = this.focusGrid_.getRowForRoot(listElement); this.focusGrid_.removeRow(focusRow); this.focusGrid_.ensureRowActive(); focusRow.destroy(); // TODO(dbeam): in a world where this UI is actually used, we should // probably move the focus before removing |listElement|. listElement.parentNode.removeChild(listElement); if (wasActive) { index = Math.min(index, this.errors_.length - 1); this.setActiveError(index); // Gracefully handles the -1 case. } chrome.developerPrivate.deleteExtensionErrors({ extensionId: error.extensionId, errorIds: [error.id] }); if (this.errors_.length == 0) this.querySelector('#no-errors-span').hidden = false; }, /** * Updates the list of errors. * @param {!Array<(ManifestError|RuntimeError)>} newErrors The new list of * errors. * @private */ updateErrors_: function(newErrors) { this.errors_.forEach(function(error) { if (findErrorById(newErrors, error.id) == -1) this.removeError_(error); }, this); newErrors.forEach(function(error) { var index = findErrorById(this.errors_, error.id); if (index == -1) this.addError_(error); else this.errors_[index] = error; // Update the existing reference. }, this); }, /** * Called when the list is being removed. */ onRemoved: function() { chrome.developerPrivate.onItemStateChanged.removeListener( this.onItemStateChangedListener_); this.clear(false); }, /** * Sets the active error in the list. * @param {number} index The index to set to be active. */ setActiveError: function(index) { var errorList = this.querySelector('.extension-error-list-contents'); var item = errorList.children[index]; this.setActiveErrorNode_( item ? item.querySelector('.extension-error-metadata') : null); var node = null; if (index >= 0 && index < errorList.children.length) { node = errorList.children[index].querySelector( '.extension-error-metadata'); } this.setActiveErrorNode_(node); }, /** * Clears the list of all errors. * @param {boolean} deleteErrors Whether or not the errors should be deleted * on the backend. */ clear: function(deleteErrors) { if (this.errors_.length == 0) return; if (deleteErrors) { var ids = this.errors_.map(function(error) { return error.id; }); chrome.developerPrivate.deleteExtensionErrors({ extensionId: this.extensionId_, errorIds: ids }); } this.setActiveErrorNode_(null); this.errors_.length = 0; var errorList = this.querySelector('.extension-error-list-contents'); while (errorList.firstChild) errorList.removeChild(errorList.firstChild); }, /** * Sets the active error in the list. * @param {?} node The error to make active. * @private */ setActiveErrorNode_: function(node) { if (this.activeError_) this.activeError_.classList.remove('extension-error-active'); if (node) node.classList.add('extension-error-active'); this.activeError_ = node; this.dispatchEvent( new CustomEvent('activeExtensionErrorChanged', {bubbles: true, detail: node ? node.error : null})); }, }; return { ExtensionErrorList: ExtensionErrorList }; }); cr.define('extensions', function() { 'use strict'; var ExtensionType = chrome.developerPrivate.ExtensionType; /** * @param {string} name The name of the template to clone. * @return {!Element} The freshly cloned template. */ function cloneTemplate(name) { var node = $('templates').querySelector('.' + name).cloneNode(true); return assertInstanceof(node, Element); } /** * @extends {HTMLElement} * @constructor */ function ExtensionWrapper() { var wrapper = cloneTemplate('extension-list-item-wrapper'); wrapper.__proto__ = ExtensionWrapper.prototype; wrapper.initialize(); return wrapper; } ExtensionWrapper.prototype = { __proto__: HTMLElement.prototype, initialize: function() { var boundary = $('extension-settings-list'); /** @private {!extensions.FocusRow} */ this.focusRow_ = new extensions.FocusRow(this, boundary); }, /** @return {!cr.ui.FocusRow} */ getFocusRow: function() { return this.focusRow_; }, /** * Add an item to the focus row and listen for |eventType| events. * @param {string} focusType A tag used to identify equivalent elements when * changing focus between rows. * @param {string} query A query to select the element to set up. * @param {string=} opt_eventType The type of event to listen to. * @param {function(Event)=} opt_handler The function that should be called * by the event. * @private */ setupColumn: function(focusType, query, opt_eventType, opt_handler) { assert(this.focusRow_.addItem(focusType, query)); if (opt_eventType) { assert(opt_handler); this.querySelector(query).addEventListener(opt_eventType, opt_handler); } }, }; var ExtensionCommandsOverlay = extensions.ExtensionCommandsOverlay; /** * Compares two extensions for the order they should appear in the list. * @param {chrome.developerPrivate.ExtensionInfo} a The first extension. * @param {chrome.developerPrivate.ExtensionInfo} b The second extension. * returns {number} -1 if A comes before B, 1 if A comes after B, 0 if equal. */ function compareExtensions(a, b) { function compare(x, y) { return x < y ? -1 : (x > y ? 1 : 0); } function compareLocation(x, y) { if (x.location == y.location) return 0; if (x.location == chrome.developerPrivate.Location.UNPACKED) return -1; if (y.location == chrome.developerPrivate.Location.UNPACKED) return 1; return 0; } return compareLocation(a, b) || compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id); } /** @interface */ function ExtensionListDelegate() {} ExtensionListDelegate.prototype = { /** * Called when the number of extensions in the list has changed. */ onExtensionCountChanged: assertNotReached, }; /** * Creates a new list of extensions. * @param {extensions.ExtensionListDelegate} delegate * @constructor * @extends {HTMLDivElement} */ function ExtensionList(delegate) { var div = document.createElement('div'); div.__proto__ = ExtensionList.prototype; div.initialize(delegate); return div; } ExtensionList.prototype = { __proto__: HTMLDivElement.prototype, /** * Indicates whether an embedded options page that was navigated to through * the '?options=' URL query has been shown to the user. This is necessary * to prevent showExtensionNodes_ from opening the options more than once. * @type {boolean} * @private */ optionsShown_: false, /** @private {!cr.ui.FocusGrid} */ focusGrid_: new cr.ui.FocusGrid(), /** * Indicates whether an uninstall dialog is being shown to prevent multiple * dialogs from being displayed. * @private {boolean} */ uninstallIsShowing_: false, /** * Indicates whether a permissions prompt is showing. * @private {boolean} */ permissionsPromptIsShowing_: false, /** * Whether or not any initial navigation (like scrolling to an extension, * or opening an options page) has occurred. * @private {boolean} */ didInitialNavigation_: false, /** * Whether or not incognito mode is available. * @private {boolean} */ incognitoAvailable_: false, /** * Whether or not the app info dialog is enabled. * @private {boolean} */ enableAppInfoDialog_: false, /** * Initializes the list. * @param {!extensions.ExtensionListDelegate} delegate */ initialize: function(delegate) { /** @private {!Array} */ this.extensions_ = []; /** @private {!extensions.ExtensionListDelegate} */ this.delegate_ = delegate; this.resetLoadFinished(); chrome.developerPrivate.onItemStateChanged.addListener( function(eventData) { var EventType = chrome.developerPrivate.EventType; switch (eventData.event_type) { case EventType.VIEW_REGISTERED: case EventType.VIEW_UNREGISTERED: case EventType.INSTALLED: case EventType.LOADED: case EventType.UNLOADED: case EventType.ERROR_ADDED: case EventType.ERRORS_REMOVED: case EventType.PREFS_CHANGED: if (eventData.extensionInfo) { this.updateOrCreateWrapper_(eventData.extensionInfo); this.focusGrid_.ensureRowActive(); } break; case EventType.UNINSTALLED: var index = this.getIndexOfExtension_(eventData.item_id); this.extensions_.splice(index, 1); this.removeWrapper_(getRequiredElement(eventData.item_id)); break; default: assertNotReached(); } if (eventData.event_type == EventType.UNLOADED) this.hideEmbeddedExtensionOptions_(eventData.item_id); if (eventData.event_type == EventType.INSTALLED || eventData.event_type == EventType.UNINSTALLED) { this.delegate_.onExtensionCountChanged(); } if (eventData.event_type == EventType.LOADED || eventData.event_type == EventType.UNLOADED || eventData.event_type == EventType.PREFS_CHANGED || eventData.event_type == EventType.UNINSTALLED) { // We update the commands overlay whenever an extension is added or // removed (other updates wouldn't affect command-ly things). We // need both UNLOADED and UNINSTALLED since the UNLOADED event results // in an extension losing active keybindings, and UNINSTALLED can // result in the "Keyboard shortcuts" link being removed. ExtensionCommandsOverlay.updateExtensionsData(this.extensions_); } }.bind(this)); }, /** * Resets the |loadFinished| promise so that it can be used again; this * is useful if the page updates and tests need to wait for it to finish. */ resetLoadFinished: function() { /** * |loadFinished| should be used for testing purposes and will be * fulfilled when this list has finished loading the first time. * @type {Promise} * */ this.loadFinished = new Promise(function(resolve, reject) { /** @private {function(?)} */ this.resolveLoadFinished_ = resolve; }.bind(this)); }, /** * Updates the extensions on the page. * @param {boolean} incognitoAvailable Whether or not incognito is allowed. * @param {boolean} enableAppInfoDialog Whether or not the app info dialog * is enabled. * @return {Promise} A promise that is resolved once the extensions data is * fully updated. */ updateExtensionsData: function(incognitoAvailable, enableAppInfoDialog) { // If we start to need more information about the extension configuration, // consider passing in the full object from the ExtensionSettings. this.incognitoAvailable_ = incognitoAvailable; this.enableAppInfoDialog_ = enableAppInfoDialog; /** @private {Promise} */ this.extensionsUpdated_ = new Promise(function(resolve, reject) { chrome.developerPrivate.getExtensionsInfo( {includeDisabled: true, includeTerminated: true}, function(extensions) { // Sort in order of unpacked vs. packed, followed by name, followed by // id. extensions.sort(compareExtensions); this.extensions_ = extensions; this.showExtensionNodes_(); // We keep the commands overlay's extension info in sync, so that we // don't duplicate the same querying logic there. ExtensionCommandsOverlay.updateExtensionsData(this.extensions_); resolve(); // |resolve| is async so it's necessary to use |then| here in order to // do work after other |then|s have finished. This is important so // elements are visible when these updates happen. this.extensionsUpdated_.then(function() { this.onUpdateFinished_(); this.resolveLoadFinished_(); }.bind(this)); }.bind(this)); }.bind(this)); return this.extensionsUpdated_; }, /** * Updates elements that need to be visible in order to update properly. * @private */ onUpdateFinished_: function() { // Cannot focus or highlight a extension if there are none, and we should // only scroll to a particular extension or open the options page once. if (this.extensions_.length == 0 || this.didInitialNavigation_) return; this.didInitialNavigation_ = true; assert(!this.hidden); assert(!this.parentElement.hidden); var idToHighlight = this.getIdQueryParam_(); if (idToHighlight) { var wrapper = $(idToHighlight); if (wrapper) { this.scrollToWrapper_(idToHighlight); var focusRow = wrapper.getFocusRow(); (focusRow.getFirstFocusable('enabled') || focusRow.getFirstFocusable('remove-enterprise') || focusRow.getFirstFocusable('website') || focusRow.getFirstFocusable('details')).focus(); } } var idToOpenOptions = this.getOptionsQueryParam_(); if (idToOpenOptions && $(idToOpenOptions)) this.showEmbeddedExtensionOptions_(idToOpenOptions, true); }, /** @return {number} The number of extensions being displayed. */ getNumExtensions: function() { return this.extensions_.length; }, /** * @param {string} id The id of the extension. * @return {number} The index of the extension with the given id. * @private */ getIndexOfExtension_: function(id) { for (var i = 0; i < this.extensions_.length; ++i) { if (this.extensions_[i].id == id) return i; } return -1; }, getIdQueryParam_: function() { return parseQueryParams(document.location)['id']; }, getOptionsQueryParam_: function() { return parseQueryParams(document.location)['options']; }, /** * Creates or updates all extension items from scratch. * @private */ showExtensionNodes_: function() { // Any node that is not updated will be removed. var seenIds = []; // Iterate over the extension data and add each item to the list. this.extensions_.forEach(function(extension) { seenIds.push(extension.id); this.updateOrCreateWrapper_(extension); }, this); this.focusGrid_.ensureRowActive(); // Remove extensions that are no longer installed. var wrappers = document.querySelectorAll( '.extension-list-item-wrapper[id]'); Array.prototype.forEach.call(wrappers, function(wrapper) { if (seenIds.indexOf(wrapper.id) < 0) this.removeWrapper_(wrapper); }, this); }, /** * Removes the wrapper from the DOM and updates the focused element if * needed. * @param {!Element} wrapper * @private */ removeWrapper_: function(wrapper) { // If focus is in the wrapper about to be removed, move it first. This // happens when clicking the trash can to remove an extension. if (wrapper.contains(document.activeElement)) { var wrappers = document.querySelectorAll( '.extension-list-item-wrapper[id]'); var index = Array.prototype.indexOf.call(wrappers, wrapper); assert(index != -1); var focusableWrapper = wrappers[index + 1] || wrappers[index - 1]; if (focusableWrapper) { var newFocusRow = focusableWrapper.getFocusRow(); newFocusRow.getEquivalentElement(document.activeElement).focus(); } } var focusRow = wrapper.getFocusRow(); this.focusGrid_.removeRow(focusRow); this.focusGrid_.ensureRowActive(); focusRow.destroy(); wrapper.parentNode.removeChild(wrapper); }, /** * Scrolls the page down to the extension node with the given id. * @param {string} extensionId The id of the extension to scroll to. * @private */ scrollToWrapper_: function(extensionId) { // Scroll offset should be calculated slightly higher than the actual // offset of the element being scrolled to, so that it ends up not all // the way at the top. That way it is clear that there are more elements // above the element being scrolled to. var wrapper = $(extensionId); var scrollFudge = 1.2; var scrollTop = wrapper.offsetTop - scrollFudge * wrapper.clientHeight; setScrollTopForDocument(document, scrollTop); }, /** * Synthesizes and initializes an HTML element for the extension metadata * given in |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension A dictionary * of extension metadata. * @param {?Element} nextWrapper The newly created wrapper will be inserted * before |nextWrapper| if non-null (else it will be appended to the * wrapper list). * @private */ createWrapper_: function(extension, nextWrapper) { var wrapper = new ExtensionWrapper; wrapper.id = extension.id; // The 'Permissions' link. wrapper.setupColumn('details', '.permissions-link', 'click', function(e) { if (!this.permissionsPromptIsShowing_) { chrome.developerPrivate.showPermissionsDialog(extension.id, function() { this.permissionsPromptIsShowing_ = false; }.bind(this)); this.permissionsPromptIsShowing_ = true; } e.preventDefault(); }); wrapper.setupColumn('options', '.options-button', 'click', function(e) { this.showEmbeddedExtensionOptions_(extension.id, false); e.preventDefault(); }.bind(this)); // The 'Options' button or link, depending on its behaviour. // Set an href to get the correct mouse-over appearance (link, // footer) - but the actual link opening is done through developerPrivate // API with a preventDefault(). wrapper.querySelector('.options-link').href = extension.optionsPage ? extension.optionsPage.url : ''; wrapper.setupColumn('options', '.options-link', 'click', function(e) { chrome.developerPrivate.showOptions(extension.id); e.preventDefault(); }); // The 'View in Web Store/View Web Site' link. wrapper.setupColumn('website', '.site-link'); // The 'Launch' link. wrapper.setupColumn('launch', '.launch-link', 'click', function(e) { chrome.management.launchApp(extension.id); }); // The 'Reload' link. wrapper.setupColumn('localReload', '.reload-link', 'click', function(e) { chrome.developerPrivate.reload(extension.id, {failQuietly: true}); }); wrapper.setupColumn('errors', '.errors-link', 'click', function(e) { var extensionId = extension.id; assert(this.extensions_.length > 0); var newEx = this.extensions_.filter(function(e) { return e.id == extensionId; })[0]; var errors = newEx.manifestErrors.concat(newEx.runtimeErrors); extensions.ExtensionErrorOverlay.getInstance().setErrorsAndShowOverlay( errors, extensionId, newEx.name); }.bind(this)); wrapper.setupColumn('suspiciousLearnMore', '.suspicious-install-message .learn-more-link'); // The path, if provided by unpacked extension. wrapper.setupColumn('loadPath', '.load-path a:first-of-type', 'click', function(e) { chrome.developerPrivate.showPath(extension.id); e.preventDefault(); }); // The 'allow in incognito' checkbox. wrapper.setupColumn('incognito', '.incognito-control input', 'change', function(e) { var butterBar = wrapper.querySelector('.butter-bar'); var checked = e.target.checked; butterBar.hidden = !checked || extension.type == ExtensionType.HOSTED_APP; chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, incognitoAccess: e.target.checked }); }.bind(this)); // The 'collect errors' checkbox. This should only be visible if the // error console is enabled - we can detect this by the existence of the // |errorCollectionEnabled| property. wrapper.setupColumn('collectErrors', '.error-collection-control input', 'change', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, errorCollection: e.target.checked }); }); // The 'allow on all urls' checkbox. This should only be visible if // active script restrictions are enabled. If they are not enabled, no // extensions should want all urls. wrapper.setupColumn('allUrls', '.all-urls-control input', 'click', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, runOnAllUrls: e.target.checked }); }); // The 'allow file:// access' checkbox. wrapper.setupColumn('localUrls', '.file-access-control input', 'click', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, fileAccess: e.target.checked }); }); // The 'Reload' terminated link. wrapper.setupColumn('terminatedReload', '.terminated-reload-link', 'click', function(e) { chrome.developerPrivate.reload(extension.id, {failQuietly: true}); }); // The 'Repair' corrupted link. wrapper.setupColumn('repair', '.corrupted-repair-button', 'click', function(e) { chrome.developerPrivate.repairExtension(extension.id); }); // The 'Enabled' checkbox. wrapper.setupColumn('enabled', '.enable-checkbox input', 'click', function(e) { var checked = e.target.checked; // TODO(devlin): What should we do if this fails? Ideally we want to // show some kind of error or feedback to the user if this fails. chrome.management.setEnabled(extension.id, checked); // This may seem counter-intuitive (to not set/clear the checkmark) // but this page will be updated asynchronously if the extension // becomes enabled/disabled. It also might not become enabled or // disabled, because the user might e.g. get prompted when enabling // and choose not to. e.preventDefault(); }); // 'Remove' button. var trash = cloneTemplate('trash'); trash.title = loadTimeData.getString('extensionUninstall'); wrapper.querySelector('.enable-controls').appendChild(trash); wrapper.setupColumn('remove-enterprise', '.trash', 'click', function(e) { trash.classList.add('open'); trash.classList.toggle('mouse-clicked', e.detail > 0); if (this.uninstallIsShowing_) return; this.uninstallIsShowing_ = true; chrome.management.uninstall(extension.id, {showConfirmDialog: true}, function() { // TODO(devlin): What should we do if the uninstall fails? this.uninstallIsShowing_ = false; if (trash.classList.contains('mouse-clicked')) trash.blur(); if (chrome.runtime.lastError) { // The uninstall failed (e.g. a cancel). Allow the trash to close. trash.classList.remove('open'); } else { // Leave the trash open if the uninstall succeded. Otherwise it can // half-close right before it's removed when the DOM is modified. } }.bind(this)); }.bind(this)); // Maintain the order that nodes should be in when creating as well as // when adding only one new wrapper. this.insertBefore(wrapper, nextWrapper); this.updateWrapper_(extension, wrapper); var nextRow = this.focusGrid_.getRowForRoot(nextWrapper); // May be null. this.focusGrid_.addRowBefore(wrapper.getFocusRow(), nextRow); }, /** * Updates an HTML element for the extension metadata given in |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension A dictionary of * extension metadata. * @param {!Element} wrapper The extension wrapper element to update. * @private */ updateWrapper_: function(extension, wrapper) { var isActive = extension.state == chrome.developerPrivate.ExtensionState.ENABLED; wrapper.classList.toggle('inactive-extension', !isActive); wrapper.classList.remove('controlled', 'may-not-remove'); if (extension.controlledInfo) { wrapper.classList.add('controlled'); } else if (!extension.userMayModify || extension.mustRemainInstalled || extension.dependentExtensions.length > 0) { wrapper.classList.add('may-not-remove'); } var item = wrapper.querySelector('.extension-list-item'); item.style.backgroundImage = 'url(' + extension.iconUrl + ')'; this.setText_(wrapper, '.extension-title', extension.name); this.setText_(wrapper, '.extension-version', extension.version); this.setText_(wrapper, '.location-text', extension.locationText || ''); this.setText_(wrapper, '.blacklist-text', extension.blacklistText || ''); this.setText_(wrapper, '.extension-description', extension.description); // The 'allow in incognito' checkbox. this.updateVisibility_(wrapper, '.incognito-control', isActive && this.incognitoAvailable_, function(item) { var incognito = item.querySelector('input'); incognito.disabled = !extension.incognitoAccess.isEnabled; incognito.checked = extension.incognitoAccess.isActive; }); var showButterBar = isActive && extension.incognitoAccess.isActive && extension.type != ExtensionType.HOSTED_APP; // The 'allow in incognito' butter bar. this.updateVisibility_(wrapper, '.butter-bar', showButterBar); // The 'collect errors' checkbox. This should only be visible if the // error console is enabled - we can detect this by the existence of the // |errorCollectionEnabled| property. this.updateVisibility_( wrapper, '.error-collection-control', isActive && extension.errorCollection.isEnabled, function(item) { item.querySelector('input').checked = extension.errorCollection.isActive; }); // The 'allow on all urls' checkbox. This should only be visible if // active script restrictions are enabled. If they are not enabled, no // extensions should want all urls. this.updateVisibility_( wrapper, '.all-urls-control', isActive && extension.runOnAllUrls.isEnabled, function(item) { item.querySelector('input').checked = extension.runOnAllUrls.isActive; }); // The 'allow file:// access' checkbox. this.updateVisibility_(wrapper, '.file-access-control', isActive && extension.fileAccess.isEnabled, function(item) { item.querySelector('input').checked = extension.fileAccess.isActive; }); // The 'Options' button or link, depending on its behaviour. var optionsEnabled = isActive && !!extension.optionsPage; this.updateVisibility_(wrapper, '.options-link', optionsEnabled && extension.optionsPage.openInTab); this.updateVisibility_(wrapper, '.options-button', optionsEnabled && !extension.optionsPage.openInTab); // The 'View in Web Store/View Web Site' link. var siteLinkEnabled = !!extension.homePage.url && !this.enableAppInfoDialog_; this.updateVisibility_(wrapper, '.site-link', siteLinkEnabled, function(item) { item.href = extension.homePage.url; item.textContent = loadTimeData.getString( extension.homePage.specified ? 'extensionSettingsVisitWebsite' : 'extensionSettingsVisitWebStore'); }); var isUnpacked = extension.location == chrome.developerPrivate.Location.UNPACKED; // The 'Reload' link. this.updateVisibility_(wrapper, '.reload-link', isActive && isUnpacked); // The 'Launch' link. this.updateVisibility_( wrapper, '.launch-link', isUnpacked && extension.type == ExtensionType.PLATFORM_APP && isActive); // The 'Errors' link. var hasErrors = extension.runtimeErrors.length > 0 || extension.manifestErrors.length > 0; this.updateVisibility_(wrapper, '.errors-link', hasErrors, function(item) { var Level = chrome.developerPrivate.ErrorLevel; var map = {}; map[Level.LOG] = {weight: 0, name: 'extension-error-info-icon'}; map[Level.WARN] = {weight: 1, name: 'extension-error-warning-icon'}; map[Level.ERROR] = {weight: 2, name: 'extension-error-fatal-icon'}; // Find the highest severity of all the errors; manifest errors all have // a 'warning' level severity. var highestSeverity = extension.runtimeErrors.reduce( function(prev, error) { return map[error.severity].weight > map[prev].weight ? error.severity : prev; }, extension.manifestErrors.length ? Level.WARN : Level.LOG); // Adjust the class on the icon. var icon = item.querySelector('.extension-error-icon'); // TODO(hcarmona): Populate alt text with a proper description since // this icon conveys the severity of the error. (info, warning, fatal). icon.alt = ''; icon.className = 'extension-error-icon'; // Remove other classes. icon.classList.add(map[highestSeverity].name); }); // The 'Reload' terminated link. var isTerminated = extension.state == chrome.developerPrivate.ExtensionState.TERMINATED; this.updateVisibility_(wrapper, '.terminated-reload-link', isTerminated); // The 'Repair' corrupted link. var canRepair = !isTerminated && extension.disableReasons.corruptInstall && extension.location == chrome.developerPrivate.Location.FROM_STORE; this.updateVisibility_(wrapper, '.corrupted-repair-button', canRepair); // The 'Enabled' checkbox. var isOK = !isTerminated && !canRepair; this.updateVisibility_(wrapper, '.enable-checkbox', isOK, function(item) { var enableCheckboxDisabled = !extension.userMayModify || extension.disableReasons.suspiciousInstall || extension.disableReasons.corruptInstall || extension.disableReasons.updateRequired || extension.dependentExtensions.length > 0 || extension.state == chrome.developerPrivate.ExtensionState.BLACKLISTED; item.querySelector('input').disabled = enableCheckboxDisabled; item.querySelector('input').checked = isActive; }); // Indicator for extensions controlled by policy. var controlNode = wrapper.querySelector('.enable-controls'); var indicator = controlNode.querySelector('.controlled-extension-indicator'); var needsIndicator = isOK && extension.controlledInfo; if (needsIndicator && !indicator) { indicator = new cr.ui.ControlledIndicator(); indicator.classList.add('controlled-extension-indicator'); var ControllerType = chrome.developerPrivate.ControllerType; var controlledByStr = ''; switch (extension.controlledInfo.type) { case ControllerType.POLICY: controlledByStr = 'policy'; break; case ControllerType.CHILD_CUSTODIAN: controlledByStr = 'child-custodian'; break; case ControllerType.SUPERVISED_USER_CUSTODIAN: controlledByStr = 'supervised-user-custodian'; break; } indicator.setAttribute('controlled-by', controlledByStr); var text = extension.controlledInfo.text; indicator.setAttribute('text' + controlledByStr, text); indicator.image.setAttribute('aria-label', text); controlNode.appendChild(indicator); wrapper.setupColumn('remove-enterprise', '[controlled-by] div'); } else if (!needsIndicator && indicator) { controlNode.removeChild(indicator); } // Developer mode //////////////////////////////////////////////////////// // First we have the id. var idLabel = wrapper.querySelector('.extension-id'); idLabel.textContent = ' ' + extension.id; // Then the path, if provided by unpacked extension. this.updateVisibility_(wrapper, '.load-path', isUnpacked, function(item) { item.querySelector('a:first-of-type').textContent = ' ' + extension.prettifiedPath; }); // Then the 'managed, cannot uninstall/disable' message. // We would like to hide managed installed message since this // extension is disabled. var isRequired = !extension.userMayModify || extension.mustRemainInstalled; this.updateVisibility_(wrapper, '.managed-message', isRequired && !extension.disableReasons.updateRequired); // Then the 'This isn't from the webstore, looks suspicious' message. var isSuspicious = extension.disableReasons.suspiciousInstall; this.updateVisibility_(wrapper, '.suspicious-install-message', !isRequired && isSuspicious); // Then the 'This is a corrupt extension' message. this.updateVisibility_(wrapper, '.corrupt-install-message', !isRequired && extension.disableReasons.corruptInstall); // Then the 'An update required by enterprise policy' message. Note that // a force-installed extension might be disabled due to being outdated // as well. this.updateVisibility_(wrapper, '.update-required-message', extension.disableReasons.updateRequired); // The 'following extensions depend on this extension' list. var hasDependents = extension.dependentExtensions.length > 0; wrapper.classList.toggle('developer-extras', hasDependents); this.updateVisibility_(wrapper, '.dependent-extensions-message', hasDependents, function(item) { var dependentList = item.querySelector('ul'); dependentList.textContent = ''; extension.dependentExtensions.forEach(function(dependentExtension) { var depNode = cloneTemplate('dependent-list-item'); depNode.querySelector('.dep-extension-title').textContent = dependentExtension.name; depNode.querySelector('.dep-extension-id').textContent = dependentExtension.id; dependentList.appendChild(depNode); }, this); }.bind(this)); // The active views. this.updateVisibility_(wrapper, '.active-views', extension.views.length > 0, function(item) { var link = item.querySelector('a'); // Link needs to be an only child before the list is updated. while (link.nextElementSibling) item.removeChild(link.nextElementSibling); // Link needs to be cleaned up if it was used before. link.textContent = ''; if (link.clickHandler) link.removeEventListener('click', link.clickHandler); extension.views.forEach(function(view, i) { if (view.type == chrome.developerPrivate.ViewType.EXTENSION_DIALOG || view.type == chrome.developerPrivate.ViewType.EXTENSION_POPUP) { return; } var displayName; if (view.url.startsWith('chrome-extension://')) { var pathOffset = 'chrome-extension://'.length + 32 + 1; displayName = view.url.substring(pathOffset); if (displayName == '_generated_background_page.html') displayName = loadTimeData.getString('backgroundPage'); } else { displayName = view.url; } var label = displayName + (view.incognito ? ' ' + loadTimeData.getString('viewIncognito') : '') + (view.renderProcessId == -1 ? ' ' + loadTimeData.getString('viewInactive') : '') + (view.isIframe ? ' ' + loadTimeData.getString('viewIframe') : ''); link.textContent = label; link.clickHandler = function(e) { chrome.developerPrivate.openDevTools({ extensionId: extension.id, renderProcessId: view.renderProcessId, renderViewId: view.renderViewId, incognito: view.incognito }); }; link.addEventListener('click', link.clickHandler); if (i < extension.views.length - 1) { link = link.cloneNode(true); item.appendChild(link); } wrapper.setupColumn('activeView', '.active-views a:last-of-type'); }); }); // The extension warnings (describing runtime issues). this.updateVisibility_(wrapper, '.extension-warnings', extension.runtimeWarnings.length > 0, function(item) { var warningList = item.querySelector('ul'); warningList.textContent = ''; extension.runtimeWarnings.forEach(function(warning) { var li = document.createElement('li'); warningList.appendChild(li).innerText = warning; }); }); // Install warnings. this.updateVisibility_(wrapper, '.install-warnings', extension.installWarnings.length > 0, function(item) { var installWarningList = item.querySelector('ul'); installWarningList.textContent = ''; if (extension.installWarnings) { extension.installWarnings.forEach(function(warning) { var li = document.createElement('li'); li.innerText = warning; installWarningList.appendChild(li); }); } }); if (location.hash.substr(1) == extension.id) { // Scroll beneath the fixed header so that the extension is not // obscured. var topScroll = wrapper.offsetTop - $('page-header').offsetHeight; var pad = parseInt(window.getComputedStyle(wrapper).marginTop, 10); if (!isNaN(pad)) topScroll -= pad / 2; setScrollTopForDocument(document, topScroll); } }, /** * Updates an element's textContent. * @param {Node} node Ancestor of the element specified by |query|. * @param {string} query A query to select an element in |node|. * @param {string} textContent * @private */ setText_: function(node, query, textContent) { node.querySelector(query).textContent = textContent; }, /** * Updates an element's visibility and calls |shownCallback| if it is * visible. * @param {Node} node Ancestor of the element specified by |query|. * @param {string} query A query to select an element in |node|. * @param {boolean} visible Whether the element should be visible or not. * @param {function(Element)=} opt_shownCallback Callback if the element is * visible. The element passed in will be the element specified by * |query|. * @private */ updateVisibility_: function(node, query, visible, opt_shownCallback) { var element = assertInstanceof(node.querySelector(query), Element); element.hidden = !visible; if (visible && opt_shownCallback) opt_shownCallback(element); }, /** * Opens the extension options overlay for the extension with the given id. * @param {string} extensionId The id of extension whose options page should * be displayed. * @param {boolean} scroll Whether the page should scroll to the extension * @private */ showEmbeddedExtensionOptions_: function(extensionId, scroll) { if (this.optionsShown_) return; // Get the extension from the given id. var extension = this.extensions_.filter(function(extension) { return extension.state == chrome.developerPrivate.ExtensionState.ENABLED && extension.id == extensionId; })[0]; if (!extension) return; if (scroll) this.scrollToWrapper_(extensionId); // Add the options query string. Corner case: the 'options' query string // will clobber the 'id' query string if the options link is clicked when // 'id' is in the URL, or if both query strings are in the URL. window.history.replaceState({}, '', '/?options=' + extensionId); var overlay = extensions.ExtensionOptionsOverlay.getInstance(); var shownCallback = function() { // This overlay doesn't get focused automatically as // is created after the overlay is shown. if (cr.ui.FocusOutlineManager.forDocument(document).visible) overlay.setInitialFocus(); }; overlay.setExtensionAndShow(extensionId, extension.name, extension.iconUrl, shownCallback); this.optionsShown_ = true; var self = this; $('overlay').addEventListener('cancelOverlay', function f() { self.optionsShown_ = false; $('overlay').removeEventListener('cancelOverlay', f); // Remove the options query string. window.history.replaceState({}, '', '/'); }); // TODO(dbeam): why do we need to focus before and // after its showing animation? Makes very little sense to me. overlay.setInitialFocus(); }, /** * Hides the extension options overlay for the extension with id * |extensionId|. If there is an overlay showing for a different extension, * nothing happens. * @param {string} extensionId ID of the extension to hide. * @private */ hideEmbeddedExtensionOptions_: function(extensionId) { if (!this.optionsShown_) return; var overlay = extensions.ExtensionOptionsOverlay.getInstance(); if (overlay.getExtensionId() == extensionId) overlay.close(); }, /** * Updates or creates a wrapper for |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension The information * about the extension to update. * @private */ updateOrCreateWrapper_: function(extension) { var currIndex = this.getIndexOfExtension_(extension.id); if (currIndex != -1) { // If there is a current version of the extension, update it with the // new version. this.extensions_[currIndex] = extension; } else { // If the extension isn't found, push it back and sort. Technically, we // could optimize by inserting it at the right location, but since this // only happens on extension install, it's not worth it. this.extensions_.push(extension); this.extensions_.sort(compareExtensions); } var wrapper = $(extension.id); if (wrapper) { this.updateWrapper_(extension, wrapper); } else { var nextExt = this.extensions_[this.extensions_.indexOf(extension) + 1]; this.createWrapper_(extension, nextExt ? $(nextExt.id) : null); } } }; return { ExtensionList: ExtensionList, ExtensionListDelegate: ExtensionListDelegate }; }); // 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 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('cr.ui', function() { /** * A class to manage focus between given horizontally arranged elements. * * Pressing left cycles backward and pressing right cycles forward in item * order. Pressing Home goes to the beginning of the list and End goes to the * end of the list. * * If an item in this row is focused, it'll stay active (accessible via tab). * If no items in this row are focused, the row can stay active until focus * changes to a node inside |this.boundary_|. If |boundary| isn't specified, * any focus change deactivates the row. * * @param {!Element} root The root of this focus row. Focus classes are * applied to |root| and all added elements must live within |root|. * @param {?Element} boundary Focus events are ignored outside of this * element. * @param {cr.ui.FocusRow.Delegate=} opt_delegate An optional event delegate. * @constructor */ function FocusRow(root, boundary, opt_delegate) { /** @type {!Element} */ this.root = root; /** @private {!Element} */ this.boundary_ = boundary || document.documentElement; /** @type {cr.ui.FocusRow.Delegate|undefined} */ this.delegate = opt_delegate; /** @protected {!EventTracker} */ this.eventTracker = new EventTracker; } /** @interface */ FocusRow.Delegate = function() {}; FocusRow.Delegate.prototype = { /** * Called when a key is pressed while on a FocusRow's item. If true is * returned, further processing is skipped. * @param {!cr.ui.FocusRow} row The row that detected a keydown. * @param {!Event} e * @return {boolean} Whether the event was handled. */ onKeydown: assertNotReached, /** * @param {!cr.ui.FocusRow} row * @param {!Event} e */ onFocus: assertNotReached, }; /** @const {string} */ FocusRow.ACTIVE_CLASS = 'focus-row-active'; /** * Whether it's possible that |element| can be focused. * @param {Element} element * @return {boolean} Whether the item is focusable. */ FocusRow.isFocusable = function(element) { if (!element || element.disabled) return false; // We don't check that element.tabIndex >= 0 here because inactive rows set // a tabIndex of -1. function isVisible(element) { assertInstanceof(element, Element); var style = window.getComputedStyle(element); if (style.visibility == 'hidden' || style.display == 'none') return false; var parent = element.parentNode; if (!parent) return false; if (parent == element.ownerDocument || parent instanceof DocumentFragment) return true; return isVisible(parent); } return isVisible(element); }; FocusRow.prototype = { /** * Register a new type of focusable element (or add to an existing one). * * Example: an (X) button might be 'delete' or 'close'. * * When FocusRow is used within a FocusGrid, these types are used to * determine equivalent controls when Up/Down are pressed to change rows. * * Another example: mutually exclusive controls that hide eachother on * activation (i.e. Play/Pause) could use the same type (i.e. 'play-pause') * to indicate they're equivalent. * * @param {string} type The type of element to track focus of. * @param {string|HTMLElement} selectorOrElement The selector of the element * from this row's root, or the element itself. * @return {boolean} Whether a new item was added. */ addItem: function(type, selectorOrElement) { assert(type); var element; if (typeof selectorOrElement == 'string') element = this.root.querySelector(selectorOrElement); else element = selectorOrElement; if (!element) return false; element.setAttribute('focus-type', type); element.tabIndex = this.isActive() ? 0 : -1; this.eventTracker.add(element, 'blur', this.onBlur_.bind(this)); this.eventTracker.add(element, 'focus', this.onFocus_.bind(this)); this.eventTracker.add(element, 'keydown', this.onKeydown_.bind(this)); this.eventTracker.add(element, 'mousedown', this.onMousedown_.bind(this)); return true; }, /** Dereferences nodes and removes event handlers. */ destroy: function() { this.eventTracker.removeAll(); }, /** * @param {!Element} sampleElement An element for to find an equivalent for. * @return {!Element} An equivalent element to focus for |sampleElement|. * @protected */ getCustomEquivalent: function(sampleElement) { return assert(this.getFirstFocusable()); }, /** * @return {!Array} All registered elements (regardless of * focusability). */ getElements: function() { var elements = this.root.querySelectorAll('[focus-type]'); return Array.prototype.slice.call(elements); }, /** * Find the element that best matches |sampleElement|. * @param {!Element} sampleElement An element from a row of the same type * which previously held focus. * @return {!Element} The element that best matches sampleElement. */ getEquivalentElement: function(sampleElement) { if (this.getFocusableElements().indexOf(sampleElement) >= 0) return sampleElement; var sampleFocusType = this.getTypeForElement(sampleElement); if (sampleFocusType) { var sameType = this.getFirstFocusable(sampleFocusType); if (sameType) return sameType; } return this.getCustomEquivalent(sampleElement); }, /** * @param {string=} opt_type An optional type to search for. * @return {?Element} The first focusable element with |type|. */ getFirstFocusable: function(opt_type) { var filter = opt_type ? '="' + opt_type + '"' : ''; var elements = this.root.querySelectorAll('[focus-type' + filter + ']'); for (var i = 0; i < elements.length; ++i) { if (cr.ui.FocusRow.isFocusable(elements[i])) return elements[i]; } return null; }, /** @return {!Array} Registered, focusable elements. */ getFocusableElements: function() { return this.getElements().filter(cr.ui.FocusRow.isFocusable); }, /** * @param {!Element} element An element to determine a focus type for. * @return {string} The focus type for |element| or '' if none. */ getTypeForElement: function(element) { return element.getAttribute('focus-type') || ''; }, /** @return {boolean} Whether this row is currently active. */ isActive: function() { return this.root.classList.contains(FocusRow.ACTIVE_CLASS); }, /** * Enables/disables the tabIndex of the focusable elements in the FocusRow. * tabIndex can be set properly. * @param {boolean} active True if tab is allowed for this row. */ makeActive: function(active) { if (active == this.isActive()) return; this.getElements().forEach(function(element) { element.tabIndex = active ? 0 : -1; }); this.root.classList.toggle(FocusRow.ACTIVE_CLASS, active); }, /** * @param {!Event} e * @private */ onBlur_: function(e) { if (!this.boundary_.contains(/** @type {Element} */ (e.relatedTarget))) return; var currentTarget = /** @type {!Element} */ (e.currentTarget); if (this.getFocusableElements().indexOf(currentTarget) >= 0) this.makeActive(false); }, /** * @param {!Event} e * @private */ onFocus_: function(e) { if (this.delegate) this.delegate.onFocus(this, e); }, /** * @param {!Event} e A mousedown event. * @private */ onMousedown_: function(e) { // Only accept left mouse clicks. if (e.button) return; // Allow the element under the mouse cursor to be focusable. if (!e.currentTarget.disabled) e.currentTarget.tabIndex = 0; }, /** * @param {!Event} e The keydown event. * @private */ onKeydown_: function(e) { var elements = this.getFocusableElements(); var currentElement = /** @type {!Element} */ (e.currentTarget); var elementIndex = elements.indexOf(currentElement); assert(elementIndex >= 0); if (this.delegate && this.delegate.onKeydown(this, e)) return; if (hasKeyModifiers(e)) return; var index = -1; if (e.key == 'ArrowLeft') index = elementIndex + (isRTL() ? 1 : -1); else if (e.key == 'ArrowRight') index = elementIndex + (isRTL() ? -1 : 1); else if (e.key == 'Home') index = 0; else if (e.key == 'End') index = elements.length - 1; var elementToFocus = elements[index]; if (elementToFocus) { this.getEquivalentElement(elementToFocus).focus(); e.preventDefault(); } }, }; return { FocusRow: FocusRow, }; }); // // 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('cr.ui', function() { /** * A class to manage grid of focusable elements in a 2D grid. For example, * given this grid: * * focusable [focused] focusable (row: 0, col: 1) * focusable focusable focusable * focusable focusable focusable * * Pressing the down arrow would result in the focus moving down 1 row and * keeping the same column: * * focusable focusable focusable * focusable [focused] focusable (row: 1, col: 1) * focusable focusable focusable * * And pressing right or tab at this point would move the focus to: * * focusable focusable focusable * focusable focusable [focused] (row: 1, col: 2) * focusable focusable focusable * * @constructor * @implements {cr.ui.FocusRow.Delegate} */ function FocusGrid() { /** @type {!Array} */ this.rows = []; } FocusGrid.prototype = { /** @private {boolean} */ ignoreFocusChange_: false, /** @override */ onFocus: function(row, e) { if (this.ignoreFocusChange_) this.ignoreFocusChange_ = false; else this.lastFocused_ = e.currentTarget; this.rows.forEach(function(r) { r.makeActive(r == row); }); }, /** @override */ onKeydown: function(row, e) { var rowIndex = this.rows.indexOf(row); assert(rowIndex >= 0); var newRow = -1; if (e.key == 'ArrowUp') newRow = rowIndex - 1; else if (e.key == 'ArrowDown') newRow = rowIndex + 1; else if (e.key == 'PageUp') newRow = 0; else if (e.key == 'PageDown') newRow = this.rows.length - 1; var rowToFocus = this.rows[newRow]; if (rowToFocus) { this.ignoreFocusChange_ = true; rowToFocus.getEquivalentElement(this.lastFocused_).focus(); e.preventDefault(); return true; } return false; }, /** * Unregisters event handlers and removes all |this.rows|. */ destroy: function() { this.rows.forEach(function(row) { row.destroy(); }); this.rows.length = 0; }, /** * @param {!Element} target A target item to find in this grid. * @return {number} The row index. -1 if not found. */ getRowIndexForTarget: function(target) { for (var i = 0; i < this.rows.length; ++i) { if (this.rows[i].getElements().indexOf(target) >= 0) return i; } return -1; }, /** * @param {Element} root An element to search for. * @return {?cr.ui.FocusRow} The row with root of |root| or null. */ getRowForRoot: function(root) { for (var i = 0; i < this.rows.length; ++i) { if (this.rows[i].root == root) return this.rows[i]; } return null; }, /** * Adds |row| to the end of this list. * @param {!cr.ui.FocusRow} row The row that needs to be added to this grid. */ addRow: function(row) { this.addRowBefore(row, null); }, /** * Adds |row| before |nextRow|. If |nextRow| is not in the list or it's * null, |row| is added to the end. * @param {!cr.ui.FocusRow} row The row that needs to be added to this grid. * @param {cr.ui.FocusRow} nextRow The row that should follow |row|. */ addRowBefore: function(row, nextRow) { row.delegate = row.delegate || this; var nextRowIndex = nextRow ? this.rows.indexOf(nextRow) : -1; if (nextRowIndex == -1) this.rows.push(row); else this.rows.splice(nextRowIndex, 0, row); }, /** * Removes a row from the focus row. No-op if row is not in the grid. * @param {cr.ui.FocusRow} row The row that needs to be removed. */ removeRow: function(row) { var nextRowIndex = row ? this.rows.indexOf(row) : -1; if (nextRowIndex > -1) this.rows.splice(nextRowIndex, 1); }, /** * Makes sure that at least one row is active. Should be called once, after * adding all rows to FocusGrid. * @param {number=} preferredRow The row to select if no other row is * active. Selects the first item if this is beyond the range of the * grid. */ ensureRowActive: function(preferredRow) { if (this.rows.length == 0) return; for (var i = 0; i < this.rows.length; ++i) { if (this.rows[i].isActive()) return; } (this.rows[preferredRow || 0] || this.rows[0]).makeActive(true); }, }; return { FocusGrid: FocusGrid, }; }); // // Copyright 2016 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('extensions', function() { 'use strict'; /** * @param {boolean} dragEnabled * @param {boolean} isMdExtensions * @param {!EventTarget} target * @constructor * @implements cr.ui.DragWrapperDelegate */ function DragAndDropHandler(dragEnabled, isMdExtensions, target) { this.dragEnabled = dragEnabled; // Behavior is different for dropped directories between MD and non-MD // extensions pages. // TODO(devlin): Delete the non-MD codepath and remove this variable when // MD extensions launches. /** @private {boolean} */ this.isMdExtensions_ = isMdExtensions; /** @private {!EventTarget} */ this.eventTarget_ = target; } // TODO(devlin): Finish un-chrome.send-ifying this implementation. DragAndDropHandler.prototype = { /** @override */ shouldAcceptDrag: function(e) { // External Extension installation can be disabled globally, e.g. while a // different overlay is already showing. if (!this.dragEnabled) return false; // We can't access filenames during the 'dragenter' event, so we have to // wait until 'drop' to decide whether to do something with the file or // not. // See: http://www.w3.org/TR/2011/WD-html5-20110113/dnd.html#concept-dnd-p return !!e.dataTransfer.types && e.dataTransfer.types.indexOf('Files') > -1; }, /** @override */ doDragEnter: function() { chrome.send('startDrag'); if (this.isMdExtensions_) chrome.developerPrivate.notifyDragInstallInProgress(); this.eventTarget_.dispatchEvent( new CustomEvent('extension-drag-started')); }, /** @override */ doDragLeave: function() { this.fireDragEnded_(); chrome.send('stopDrag'); }, /** @override */ doDragOver: function(e) { e.preventDefault(); }, /** @override */ doDrop: function(e) { this.fireDragEnded_(); if (e.dataTransfer.files.length != 1) return; let handled = false; // Files lack a check if they're a directory, but we can find out through // its item entry. let item = e.dataTransfer.items[0]; if (item.kind === 'file' && item.webkitGetAsEntry().isDirectory) { handled = true; this.handleDirectoryDrop_(); } else if (/\.(crx|user\.js|zip)$/i.test(e.dataTransfer.files[0].name)) { // Only process files that look like extensions. Other files should // navigate the browser normally. handled = true; this.handleFileDrop_(); } if (handled) e.preventDefault(); }, /** * Handles a dropped file. * @private */ handleFileDrop_: function() { // Packaged files always go through chrome.send (for now). chrome.send('installDroppedFile'); }, /** * Handles a dropped directory. * @private */ handleDirectoryDrop_: function() { // Dropped directories either go through developerPrivate or chrome.send // depending on if this is the MD page. if (!this.isMdExtensions_) { chrome.send('installDroppedDirectory'); return; } // TODO(devlin): Update this to use extensions.Service when it's not // shared between the MD and non-MD pages. chrome.developerPrivate.loadUnpacked( {failQuietly: true, populateError: true, useDraggedPath: true}, (loadError) => { if (loadError) { this.eventTarget_.dispatchEvent(new CustomEvent( 'drag-and-drop-load-error', {detail: loadError})); } }); }, /** @private */ fireDragEnded_: function() { this.eventTarget_.dispatchEvent(new CustomEvent('extension-drag-ended')); } }; return { DragAndDropHandler: DragAndDropHandler, }; }); // // 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. /** * @typedef {{afterHighlight: string, * beforeHighlight: string, * highlight: string, * title: string}} */ var ExtensionHighlight; cr.define('extensions', function() { 'use strict'; /** * ExtensionCode is an element which displays code in a styled div, and is * designed to highlight errors. * @constructor * @extends {HTMLDivElement} */ function ExtensionCode(div) { div.__proto__ = ExtensionCode.prototype; return div; } ExtensionCode.prototype = { __proto__: HTMLDivElement.prototype, /** * Populate the content area of the code div with the given code. This will * highlight the erroneous section (if any). * @param {?ExtensionHighlight} code The 'highlight' strings represent the * three portions of the file's content to display - the portion which * is most relevant and should be emphasized (highlight), and the parts * both before and after this portion. The title is the error message, * which will be the mouseover hint for the highlighted region. These * may be empty. * @param {string} emptyMessage The message to display if the code * object is empty (e.g., 'could not load code'). */ populate: function(code, emptyMessage) { // Clear any remnant content, so we don't have multiple code listed. this.clear(); // If there's no code, then display an appropriate message. if (!code || (!code.highlight && !code.beforeHighlight && !code.afterHighlight)) { var span = document.createElement('span'); span.classList.add('extension-code-empty'); span.textContent = emptyMessage; this.appendChild(span); return; } var sourceDiv = document.createElement('div'); sourceDiv.classList.add('extension-code-source'); this.appendChild(sourceDiv); var lineCount = 0; var createSpan = function(source, isHighlighted) { lineCount += source.split('\n').length - 1; var span = document.createElement('span'); span.className = isHighlighted ? 'extension-code-highlighted-source' : 'extension-code-normal-source'; span.textContent = source; return span; }; if (code.beforeHighlight) sourceDiv.appendChild(createSpan(code.beforeHighlight, false)); if (code.highlight) { var highlightSpan = createSpan(code.highlight, true); highlightSpan.title = code.message; sourceDiv.appendChild(highlightSpan); } if (code.afterHighlight) sourceDiv.appendChild(createSpan(code.afterHighlight, false)); // Make the line numbers. This should be the number of line breaks + 1 // (the last line doesn't break, but should still be numbered). var content = ''; for (var i = 1; i < lineCount + 1; ++i) content += i + '\n'; var span = document.createElement('span'); span.textContent = content; var linesDiv = document.createElement('div'); linesDiv.classList.add('extension-code-line-numbers'); linesDiv.appendChild(span); this.insertBefore(linesDiv, this.firstChild); }, /** * Clears the content of the element. */ clear: function() { while (this.firstChild) this.removeChild(this.firstChild); }, /** * Scrolls to the error, if there is one. This cannot be called when the * div is hidden. */ scrollToError: function() { var errorSpan = this.querySelector('.extension-code-highlighted-source'); if (errorSpan) this.scrollTop = errorSpan.offsetTop - this.clientHeight / 2; } }; // Export return { ExtensionCode: ExtensionCode }; }); // // 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. // // Copyright 2016 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('extensions', function() { 'use strict'; /** @enum {number} */ const Key = { Comma: 188, Del: 46, Down: 40, End: 35, Escape: 27, Home: 36, Ins: 45, Left: 37, MediaNextTrack: 176, MediaPlayPause: 179, MediaPrevTrack: 177, MediaStop: 178, PageDown: 34, PageUp: 33, Period: 190, Right: 39, Space: 32, Tab: 9, Up: 38, }; /** * Enum for whether we require modifiers of a keycode. * @enum {number} */ const ModifierPolicy = {NOT_ALLOWED: 0, REQUIRED: 1}; /** * Gets the ModifierPolicy. Currently only "MediaNextTrack", "MediaPrevTrack", * "MediaStop", "MediaPlayPause" are required to be used without any modifier. * @param {number} keyCode * @return {ModifierPolicy} */ function getModifierPolicy(keyCode) { switch (keyCode) { case Key.MediaNextTrack: case Key.MediaPlayPause: case Key.MediaPrevTrack: case Key.MediaStop: return ModifierPolicy.NOT_ALLOWED; default: return ModifierPolicy.REQUIRED; } } /** * Returns whether the keyboard event has a key modifier, which could affect * how it's handled. * @param {!KeyboardEvent} e * @param {boolean} countShiftAsModifier Whether the 'Shift' key should be * counted as modifier. * @return {boolean} True if the event has any modifiers. */ function hasModifier(e, countShiftAsModifier) { return e.ctrlKey || e.altKey || // Meta key is only relevant on Mac and CrOS, where we treat Command // and Search (respectively) as modifiers. (cr.isMac && e.metaKey) || (cr.isChromeOS && e.metaKey) || (countShiftAsModifier && e.shiftKey); } /** * Checks whether the passed in |keyCode| is a valid extension command key. * @param {number} keyCode * @return {boolean} Whether the key is valid. */ function isValidKeyCode(keyCode) { if (keyCode == Key.Escape) return false; for (let k in Key) { if (Key[k] == keyCode) return true; } return (keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) || (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0)); } /** * Converts a keystroke event to string form, ignoring invalid extension * commands. * @param {!KeyboardEvent} e * @return {string} The keystroke as a string. */ function keystrokeToString(e) { let output = []; // TODO(devlin): Should this be i18n'd? if (cr.isMac && e.metaKey) output.push('Command'); if (cr.isChromeOS && e.metaKey) output.push('Search'); if (e.ctrlKey) output.push('Ctrl'); if (!e.ctrlKey && e.altKey) output.push('Alt'); if (e.shiftKey) output.push('Shift'); let keyCode = e.keyCode; if (isValidKeyCode(keyCode)) { if ((keyCode >= 'A'.charCodeAt(0) && keyCode <= 'Z'.charCodeAt(0)) || (keyCode >= '0'.charCodeAt(0) && keyCode <= '9'.charCodeAt(0))) { output.push(String.fromCharCode(keyCode)); } else { switch (keyCode) { case Key.Comma: output.push('Comma'); break; case Key.Del: output.push('Delete'); break; case Key.Down: output.push('Down'); break; case Key.End: output.push('End'); break; case Key.Home: output.push('Home'); break; case Key.Ins: output.push('Insert'); break; case Key.Left: output.push('Left'); break; case Key.MediaNextTrack: output.push('MediaNextTrack'); break; case Key.MediaPlayPause: output.push('MediaPlayPause'); break; case Key.MediaPrevTrack: output.push('MediaPrevTrack'); break; case Key.MediaStop: output.push('MediaStop'); break; case Key.PageDown: output.push('PageDown'); break; case Key.PageUp: output.push('PageUp'); break; case Key.Period: output.push('Period'); break; case Key.Right: output.push('Right'); break; case Key.Space: output.push('Space'); break; case Key.Tab: output.push('Tab'); break; case Key.Up: output.push('Up'); break; } } } return output.join('+'); } /** * Returns true if the event has valid modifiers. * @param {!KeyboardEvent} e The keyboard event to consider. * @return {boolean} True if the event is valid. */ function hasValidModifiers(e) { switch (getModifierPolicy(e.keyCode)) { case ModifierPolicy.REQUIRED: return hasModifier(e, false); case ModifierPolicy.NOT_ALLOWED: return !hasModifier(e, true); } assertNotReached(); } return { isValidKeyCode: isValidKeyCode, keystrokeToString: keystrokeToString, hasValidModifiers: hasValidModifiers, Key: Key, }; }); cr.define('extensions', function() { 'use strict'; /** * Creates a new list of extension commands. * @param {HTMLDivElement} div * @constructor * @extends {HTMLDivElement} */ function ExtensionCommandList(div) { div.__proto__ = ExtensionCommandList.prototype; return div; } ExtensionCommandList.prototype = { __proto__: HTMLDivElement.prototype, /** * While capturing, this records the current (last) keyboard event generated * by the user. Will be |null| after capture and during capture when no * keyboard event has been generated. * @type {KeyboardEvent}. * @private */ currentKeyEvent_: null, /** * While capturing, this keeps track of the previous selection so we can * revert back to if no valid assignment is made during capture. * @type {string}. * @private */ oldValue_: '', /** * While capturing, this keeps track of which element the user asked to * change. * @type {HTMLElement}. * @private */ capturingElement_: null, /** * Updates the extensions data for the overlay. * @param {!Array} data The extension * data. */ setData: function(data) { /** @private {!Array} */ this.data_ = data; this.textContent = ''; // Iterate over the extension data and add each item to the list. this.data_.forEach(this.createNodeForExtension_.bind(this)); }, /** * Synthesizes and initializes an HTML element for the extension command * metadata given in |extension|. * @param {chrome.developerPrivate.ExtensionInfo} extension A dictionary of * extension metadata. * @private */ createNodeForExtension_: function(extension) { if (extension.commands.length == 0 || extension.state == chrome.developerPrivate.ExtensionState.DISABLED) return; var template = $('template-collection-extension-commands').querySelector( '.extension-command-list-extension-item-wrapper'); var node = template.cloneNode(true); var title = node.querySelector('.extension-title'); title.textContent = extension.name; this.appendChild(node); // Iterate over the commands data within the extension and add each item // to the list. extension.commands.forEach( this.createNodeForCommand_.bind(this, extension.id)); }, /** * Synthesizes and initializes an HTML element for the extension command * metadata given in |command|. * @param {string} extensionId The associated extension's id. * @param {chrome.developerPrivate.Command} command A dictionary of * extension command metadata. * @private */ createNodeForCommand_: function(extensionId, command) { var template = $('template-collection-extension-commands').querySelector( '.extension-command-list-command-item-wrapper'); var node = template.cloneNode(true); node.id = this.createElementId_('command', extensionId, command.name); var description = node.querySelector('.command-description'); description.textContent = command.description; var shortcutNode = node.querySelector('.command-shortcut-text'); shortcutNode.addEventListener('mouseup', this.startCapture_.bind(this)); shortcutNode.addEventListener('focus', this.handleFocus_.bind(this)); shortcutNode.addEventListener('blur', this.handleBlur_.bind(this)); shortcutNode.addEventListener('keydown', this.handleKeyDown_.bind(this)); shortcutNode.addEventListener('keyup', this.handleKeyUp_.bind(this)); if (!command.isActive) { shortcutNode.textContent = loadTimeData.getString('extensionCommandsInactive'); var commandShortcut = node.querySelector('.command-shortcut'); commandShortcut.classList.add('inactive-keybinding'); } else { shortcutNode.textContent = command.keybinding; } var commandClear = node.querySelector('.command-clear'); commandClear.id = this.createElementId_( 'clear', extensionId, command.name); commandClear.title = loadTimeData.getString('extensionCommandsDelete'); commandClear.addEventListener('click', this.handleClear_.bind(this)); var select = node.querySelector('.command-scope'); select.id = this.createElementId_( 'setCommandScope', extensionId, command.name); select.hidden = false; // Add the 'In Chrome' option. var option = document.createElement('option'); option.textContent = loadTimeData.getString('extensionCommandsRegular'); select.appendChild(option); if (command.isExtensionAction || !command.isActive) { // Extension actions cannot be global, so we might as well disable the // combo box, to signify that, and if the command is inactive, it // doesn't make sense to allow the user to adjust the scope. select.disabled = true; } else { // Add the 'Global' option. option = document.createElement('option'); option.textContent = loadTimeData.getString('extensionCommandsGlobal'); select.appendChild(option); select.selectedIndex = command.scope == chrome.developerPrivate.CommandScope.GLOBAL ? 1 : 0; select.addEventListener( 'change', this.handleSetCommandScope_.bind(this)); } this.appendChild(node); }, /** * Starts keystroke capture to determine which key to use for a particular * extension command. * @param {Event} event The keyboard event to consider. * @private */ startCapture_: function(event) { if (this.capturingElement_) return; // Already capturing. chrome.developerPrivate.setShortcutHandlingSuspended(true); var shortcutNode = event.target; this.oldValue_ = shortcutNode.textContent; shortcutNode.textContent = loadTimeData.getString('extensionCommandsStartTyping'); shortcutNode.parentElement.classList.add('capturing'); var commandClear = shortcutNode.parentElement.querySelector('.command-clear'); commandClear.hidden = true; this.capturingElement_ = /** @type {HTMLElement} */(event.target); }, /** * Ends keystroke capture and either restores the old value or (if valid * value) sets the new value as active.. * @param {Event} event The keyboard event to consider. * @private */ endCapture_: function(event) { if (!this.capturingElement_) return; // Not capturing. chrome.developerPrivate.setShortcutHandlingSuspended(false); var shortcutNode = this.capturingElement_; var commandShortcut = shortcutNode.parentElement; commandShortcut.classList.remove('capturing'); commandShortcut.classList.remove('contains-chars'); // When the capture ends, the user may have not given a complete and valid // input (or even no input at all). Only a valid key event followed by a // valid key combination will cause a shortcut selection to be activated. // If no valid selection was made, however, revert back to what the // textbox had before to indicate that the shortcut registration was // canceled. if (!this.currentKeyEvent_ || !extensions.isValidKeyCode(this.currentKeyEvent_.keyCode)) shortcutNode.textContent = this.oldValue_; var commandClear = commandShortcut.querySelector('.command-clear'); if (this.oldValue_ == '') { commandShortcut.classList.remove('clearable'); commandClear.hidden = true; } else { commandShortcut.classList.add('clearable'); commandClear.hidden = false; } this.oldValue_ = ''; this.capturingElement_ = null; this.currentKeyEvent_ = null; }, /** * Handles focus event and adds visual indication for active shortcut. * @param {Event} event to consider. * @private */ handleFocus_: function(event) { var commandShortcut = event.target.parentElement; commandShortcut.classList.add('focused'); }, /** * Handles lost focus event and removes visual indication of active shortcut * also stops capturing on focus lost. * @param {Event} event to consider. * @private */ handleBlur_: function(event) { this.endCapture_(event); var commandShortcut = event.target.parentElement; commandShortcut.classList.remove('focused'); }, /** * The KeyDown handler. * @param {Event} event The keyboard event to consider. * @private */ handleKeyDown_: function(event) { event = /** @type {KeyboardEvent} */(event); if (event.keyCode == extensions.Key.Escape) { if (!this.capturingElement_) { // If we're not currently capturing, allow escape to propagate (so it // can close the overflow). return; } // Otherwise, escape cancels capturing. this.endCapture_(event); var parsed = this.parseElementId_('clear', event.target.parentElement.querySelector('.command-clear').id); chrome.developerPrivate.updateExtensionCommand({ extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: '' }); event.preventDefault(); event.stopPropagation(); return; } if (event.keyCode == extensions.Key.Tab) { // Allow tab propagation for keyboard navigation. return; } if (!this.capturingElement_) this.startCapture_(event); this.handleKey_(event); }, /** * The KeyUp handler. * @param {Event} event The keyboard event to consider. * @private */ handleKeyUp_: function(event) { event = /** @type {KeyboardEvent} */(event); if (event.keyCode == extensions.Key.Tab || event.keyCode == extensions.Key.Escape) { // We need to allow tab propagation for keyboard navigation, and escapes // are fully handled in handleKeyDown. return; } // We want to make it easy to change from Ctrl+Shift+ to just Ctrl+ by // releasing Shift, but we also don't want it to be easy to lose for // example Ctrl+Shift+F to Ctrl+ just because you didn't release Ctrl // as fast as the other two keys. Therefore, we process KeyUp until you // have a valid combination and then stop processing it (meaning that once // you have a valid combination, we won't change it until the next // KeyDown message arrives). if (!this.currentKeyEvent_ || !extensions.isValidKeyCode(this.currentKeyEvent_.keyCode)) { if (!event.ctrlKey && !event.altKey || ((cr.isMac || cr.isChromeOS) && !event.metaKey)) { // If neither Ctrl nor Alt is pressed then it is not a valid shortcut. // That means we're back at the starting point so we should restart // capture. this.endCapture_(event); this.startCapture_(event); } else { this.handleKey_(event); } } }, /** * A general key handler (used for both KeyDown and KeyUp). * @param {KeyboardEvent} event The keyboard event to consider. * @private */ handleKey_: function(event) { // While capturing, we prevent all events from bubbling, to prevent // shortcuts lacking the right modifier (F3 for example) from activating // and ending capture prematurely. event.preventDefault(); event.stopPropagation(); if (!extensions.hasValidModifiers(event)) return; var shortcutNode = this.capturingElement_; var keystroke = extensions.keystrokeToString(event); shortcutNode.textContent = keystroke; event.target.classList.add('contains-chars'); this.currentKeyEvent_ = event; if (extensions.isValidKeyCode(event.keyCode)) { var node = event.target; while (node && !node.id) node = node.parentElement; this.oldValue_ = keystroke; // Forget what the old value was. var parsed = this.parseElementId_('command', node.id); // Ending the capture must occur before calling // setExtensionCommandShortcut to ensure the shortcut is set. this.endCapture_(event); chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: keystroke}); } }, /** * A handler for the delete command button. * @param {Event} event The mouse event to consider. * @private */ handleClear_: function(event) { var parsed = this.parseElementId_('clear', event.target.id); chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, keybinding: ''}); }, /** * A handler for the setting the scope of the command. * @param {Event} event The mouse event to consider. * @private */ handleSetCommandScope_: function(event) { var parsed = this.parseElementId_('setCommandScope', event.target.id); var element = $('setCommandScope-' + parsed.extensionId + '-' + parsed.commandName); var scope = element.selectedIndex == 1 ? chrome.developerPrivate.CommandScope.GLOBAL : chrome.developerPrivate.CommandScope.CHROME; chrome.developerPrivate.updateExtensionCommand( {extensionId: parsed.extensionId, commandName: parsed.commandName, scope: scope}); }, /** * A utility function to create a unique element id based on a namespace, * extension id and a command name. * @param {string} namespace The namespace to prepend the id with. * @param {string} extensionId The extension ID to use in the id. * @param {string} commandName The command name to append the id with. * @private */ createElementId_: function(namespace, extensionId, commandName) { return namespace + '-' + extensionId + '-' + commandName; }, /** * A utility function to parse a unique element id based on a namespace, * extension id and a command name. * @param {string} namespace The namespace to prepend the id with. * @param {string} id The id to parse. * @return {{extensionId: string, commandName: string}} The parsed id. * @private */ parseElementId_: function(namespace, id) { var kExtensionIdLength = 32; return { extensionId: id.substring(namespace.length + 1, namespace.length + 1 + kExtensionIdLength), commandName: id.substring(namespace.length + 1 + kExtensionIdLength + 1) }; }, }; return { ExtensionCommandList: ExtensionCommandList }; }); cr.define('extensions', function() { 'use strict'; // The Extension Commands list object that will be used to show the commands // on the page. var ExtensionCommandList = extensions.ExtensionCommandList; /** * ExtensionCommandsOverlay class * Encapsulated handling of the 'Extension Commands' overlay page. * @constructor */ function ExtensionCommandsOverlay() { } cr.addSingletonGetter(ExtensionCommandsOverlay); ExtensionCommandsOverlay.prototype = { /** * Initialize the page. */ initializePage: function() { var overlay = $('overlay'); cr.ui.overlay.setupOverlay(overlay); cr.ui.overlay.globalInitialization(); overlay.addEventListener('cancelOverlay', this.handleDismiss_.bind(this)); this.extensionCommandList_ = new ExtensionCommandList( /**@type {HTMLDivElement} */($('extension-command-list'))); $('extension-commands-dismiss').addEventListener('click', function() { cr.dispatchSimpleEvent(overlay, 'cancelOverlay'); }); // The ExtensionList will update us with its data, so we don't need to // handle that here. }, /** * Handles a click on the dismiss button. * @param {Event} e The click event. */ handleDismiss_: function(e) { extensions.ExtensionSettings.showOverlay(null); }, }; /** * Called by the dom_ui_ to re-populate the page with data representing * the current state of extension commands. * @param {!Array} extensionsData */ ExtensionCommandsOverlay.updateExtensionsData = function(extensionsData) { var overlay = ExtensionCommandsOverlay.getInstance(); overlay.extensionCommandList_.setData(extensionsData); var hasAnyCommands = false; for (var i = 0; i < extensionsData.length; ++i) { if (extensionsData[i].commands.length > 0) { hasAnyCommands = true; break; } } // Make sure the config link is visible, since there are commands to show // and potentially configure. document.querySelector('.extension-commands-config').hidden = !hasAnyCommands; $('no-commands').hidden = hasAnyCommands; overlay.extensionCommandList_.classList.toggle( 'empty-extension-commands-list', !hasAnyCommands); }; // Export return { ExtensionCommandsOverlay: ExtensionCommandsOverlay }; }); // // 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. /** @typedef {chrome.developerPrivate.RuntimeError} */ var RuntimeError; /** @typedef {chrome.developerPrivate.ManifestError} */ var ManifestError; cr.define('extensions', function() { 'use strict'; /** * Clear all the content of a given element. * @param {HTMLElement} element The element to be cleared. */ function clearElement(element) { while (element.firstChild) element.removeChild(element.firstChild); } /** * Get the url relative to the main extension url. If the url is * unassociated with the extension, this will be the full url. * @param {string} url The url to make relative. * @param {string} extensionUrl The url for the extension resources, in the * form "chrome-etxension:///". * @return {string} The url relative to the host. */ function getRelativeUrl(url, extensionUrl) { return url.substring(0, extensionUrl.length) == extensionUrl ? url.substring(extensionUrl.length) : url; } /** * The RuntimeErrorContent manages all content specifically associated with * runtime errors; this includes stack frames and the context url. * @constructor * @extends {HTMLDivElement} */ function RuntimeErrorContent() { var contentArea = $('template-collection-extension-error-overlay'). querySelector('.extension-error-overlay-runtime-content'). cloneNode(true); contentArea.__proto__ = RuntimeErrorContent.prototype; contentArea.init(); return contentArea; } /** * The name of the "active" class specific to extension errors (so as to * not conflict with other rules). * @type {string} * @const */ RuntimeErrorContent.ACTIVE_CLASS_NAME = 'extension-error-active'; /** * Determine whether or not we should display the url to the user. We don't * want to include any of our own code in stack traces. * @param {string} url The url in question. * @return {boolean} True if the url should be displayed, and false * otherwise (i.e., if it is an internal script). */ RuntimeErrorContent.shouldDisplayForUrl = function(url) { // All our internal scripts are in the 'extensions::' namespace. return !/^extensions::/.test(url); }; RuntimeErrorContent.prototype = { __proto__: HTMLDivElement.prototype, /** * The underlying error whose details are being displayed. * @type {?(RuntimeError|ManifestError)} * @private */ error_: null, /** * The URL associated with this extension, i.e. chrome-extension:///. * @type {?string} * @private */ extensionUrl_: null, /** * The node of the stack trace which is currently active. * @type {?HTMLElement} * @private */ currentFrameNode_: null, /** * Initialize the RuntimeErrorContent for the first time. */ init: function() { /** * The stack trace element in the overlay. * @type {HTMLElement} * @private */ this.stackTrace_ = /** @type {HTMLElement} */( this.querySelector('.extension-error-overlay-stack-trace-list')); assert(this.stackTrace_); /** * The context URL element in the overlay. * @type {HTMLElement} * @private */ this.contextUrl_ = /** @type {HTMLElement} */( this.querySelector('.extension-error-overlay-context-url')); assert(this.contextUrl_); }, /** * Sets the error for the content. * @param {(RuntimeError|ManifestError)} error The error whose content * should be displayed. * @param {string} extensionUrl The URL associated with this extension. */ setError: function(error, extensionUrl) { this.clearError(); this.error_ = error; this.extensionUrl_ = extensionUrl; this.contextUrl_.textContent = error.contextUrl ? getRelativeUrl(error.contextUrl, this.extensionUrl_) : loadTimeData.getString('extensionErrorOverlayContextUnknown'); this.initStackTrace_(); }, /** * Wipe content associated with a specific error. */ clearError: function() { this.error_ = null; this.extensionUrl_ = null; this.currentFrameNode_ = null; clearElement(this.stackTrace_); this.stackTrace_.hidden = true; }, /** * Makes |frame| active and deactivates the previously active frame (if * there was one). * @param {HTMLElement} frameNode The frame to activate. * @private */ setActiveFrame_: function(frameNode) { if (this.currentFrameNode_) { this.currentFrameNode_.classList.remove( RuntimeErrorContent.ACTIVE_CLASS_NAME); } this.currentFrameNode_ = frameNode; this.currentFrameNode_.classList.add( RuntimeErrorContent.ACTIVE_CLASS_NAME); }, /** * Initialize the stack trace element of the overlay. * @private */ initStackTrace_: function() { for (var i = 0; i < this.error_.stackTrace.length; ++i) { var frame = this.error_.stackTrace[i]; // Don't include any internal calls (e.g., schemaBindings) in the // stack trace. if (!RuntimeErrorContent.shouldDisplayForUrl(frame.url)) continue; var frameNode = document.createElement('li'); // Attach the index of the frame to which this node refers (since we // may skip some, this isn't a 1-to-1 match). frameNode.indexIntoTrace = i; // The description is a human-readable summation of the frame, in the // form ": (function)", e.g. // "myfile.js:25 (myFunction)". var description = getRelativeUrl(frame.url, assert(this.extensionUrl_)) + ':' + frame.lineNumber; if (frame.functionName) { var functionName = frame.functionName == '(anonymous function)' ? loadTimeData.getString('extensionErrorOverlayAnonymousFunction') : frame.functionName; description += ' (' + functionName + ')'; } frameNode.textContent = description; // When the user clicks on a frame in the stack trace, we should // highlight that overlay in the list, display the appropriate source // code with the line highlighted, and link the "Open DevTools" button // with that frame. frameNode.addEventListener('click', function(frame, frameNode, e) { this.setActiveFrame_(frameNode); // Request the file source with the section highlighted. extensions.ExtensionErrorOverlay.getInstance().requestFileSource( {extensionId: this.error_.extensionId, message: this.error_.message, pathSuffix: getRelativeUrl(frame.url, assert(this.extensionUrl_)), lineNumber: frame.lineNumber}); }.bind(this, frame, frameNode)); this.stackTrace_.appendChild(frameNode); } // Set the current stack frame to the first stack frame and show the // trace, if one exists. (We can't just check error.stackTrace, because // it's possible the trace was purely internal, and we don't show // internal frames.) if (this.stackTrace_.children.length > 0) { this.stackTrace_.hidden = false; this.setActiveFrame_(assertInstanceof(this.stackTrace_.firstChild, HTMLElement)); } }, /** * Open the developer tools for the active stack frame. */ openDevtools: function() { var stackFrame = this.error_.stackTrace[this.currentFrameNode_.indexIntoTrace]; chrome.developerPrivate.openDevTools( {renderProcessId: this.error_.renderProcessId || -1, renderViewId: this.error_.renderViewId || -1, url: stackFrame.url, lineNumber: stackFrame.lineNumber || 0, columnNumber: stackFrame.columnNumber || 0}); } }; /** * The ExtensionErrorOverlay will show the contents of a file which pertains * to the ExtensionError; this is either the manifest file (for manifest * errors) or a source file (for runtime errors). If possible, the portion * of the file which caused the error will be highlighted. * @constructor */ function ExtensionErrorOverlay() { /** * The content section for runtime errors; this is re-used for all * runtime errors and attached/detached from the overlay as needed. * @type {RuntimeErrorContent} * @private */ this.runtimeErrorContent_ = new RuntimeErrorContent(); } /** * The manifest filename. * @type {string} * @const * @private */ ExtensionErrorOverlay.MANIFEST_FILENAME_ = 'manifest.json'; /** * Determine whether or not chrome can load the source for a given file; this * can only be done if the file belongs to the extension. * @param {string} file The file to load. * @param {string} extensionUrl The url for the extension, in the form * chrome-extension:///. * @return {boolean} True if the file can be loaded, false otherwise. * @private */ ExtensionErrorOverlay.canLoadFileSource = function(file, extensionUrl) { return file.substr(0, extensionUrl.length) == extensionUrl || file.toLowerCase() == ExtensionErrorOverlay.MANIFEST_FILENAME_; }; cr.addSingletonGetter(ExtensionErrorOverlay); ExtensionErrorOverlay.prototype = { /** * The underlying error whose details are being displayed. * @type {?(RuntimeError|ManifestError)} * @private */ selectedError_: null, /** * Initialize the page. * @param {function(HTMLDivElement)} showOverlay The function to show or * hide the ExtensionErrorOverlay; this should take a single parameter * which is either the overlay Div if the overlay should be displayed, * or null if the overlay should be hidden. */ initializePage: function(showOverlay) { var overlay = $('overlay'); cr.ui.overlay.setupOverlay(overlay); cr.ui.overlay.globalInitialization(); overlay.addEventListener('cancelOverlay', this.handleDismiss_.bind(this)); $('extension-error-overlay-dismiss').addEventListener('click', function() { cr.dispatchSimpleEvent(overlay, 'cancelOverlay'); }); /** * The element of the full overlay. * @type {HTMLDivElement} * @private */ this.overlayDiv_ = /** @type {HTMLDivElement} */( $('extension-error-overlay')); /** * The portion of the overlay which shows the code relating to the error * and the corresponding line numbers. * @type {extensions.ExtensionCode} * @private */ this.codeDiv_ = new extensions.ExtensionCode($('extension-error-overlay-code')); /** * The function to show or hide the ExtensionErrorOverlay. * @param {boolean} isVisible Whether the overlay should be visible. */ this.setVisible = function(isVisible) { showOverlay(isVisible ? this.overlayDiv_ : null); if (isVisible) this.codeDiv_.scrollToError(); }; /** * The button to open the developer tools (only available for runtime * errors). * @type {HTMLButtonElement} * @private */ this.openDevtoolsButton_ = /** @type {HTMLButtonElement} */( $('extension-error-overlay-devtools-button')); this.openDevtoolsButton_.addEventListener('click', function() { this.runtimeErrorContent_.openDevtools(); }.bind(this)); }, /** * Handles a click on the dismiss ("OK" or close) buttons. * @param {Event} e The click event. * @private */ handleDismiss_: function(e) { this.setVisible(false); // There's a chance that the overlay receives multiple dismiss events; in // this case, handle it gracefully and return (since all necessary work // will already have been done). if (!this.selectedError_) return; // Remove all previous content. this.codeDiv_.clear(); this.overlayDiv_.querySelector('.extension-error-list').onRemoved(); this.clearRuntimeContent_(); this.selectedError_ = null; }, /** * Clears the current content. * @private */ clearRuntimeContent_: function() { if (this.runtimeErrorContent_.parentNode) { this.runtimeErrorContent_.parentNode.removeChild( this.runtimeErrorContent_); this.runtimeErrorContent_.clearError(); } this.openDevtoolsButton_.hidden = true; }, /** * Sets the active error for the overlay. * @param {?(ManifestError|RuntimeError)} error The error to make active. * @private */ setActiveError_: function(error) { this.selectedError_ = error; // If there is no error (this can happen if, e.g., the user deleted all // the errors), then clear the content. if (!error) { this.codeDiv_.populate( null, loadTimeData.getString('extensionErrorNoErrorsCodeMessage')); this.clearRuntimeContent_(); return; } var extensionUrl = 'chrome-extension://' + error.extensionId + '/'; // Set or hide runtime content. if (error.type == chrome.developerPrivate.ErrorType.RUNTIME) { this.runtimeErrorContent_.setError(error, extensionUrl); this.overlayDiv_.querySelector('.content-area').insertBefore( this.runtimeErrorContent_, this.codeDiv_.nextSibling); this.openDevtoolsButton_.hidden = false; this.openDevtoolsButton_.disabled = !error.canInspect; } else { this.clearRuntimeContent_(); } // Read the file source to populate the code section, or set it to null if // the file is unreadable. if (ExtensionErrorOverlay.canLoadFileSource(error.source, extensionUrl)) { // Use pathname instead of relativeUrl. var requestFileSourceArgs = {extensionId: error.extensionId, message: error.message}; switch (error.type) { case chrome.developerPrivate.ErrorType.MANIFEST: requestFileSourceArgs.pathSuffix = error.source; requestFileSourceArgs.manifestKey = error.manifestKey; requestFileSourceArgs.manifestSpecific = error.manifestSpecific; break; case chrome.developerPrivate.ErrorType.RUNTIME: // slice(1) because pathname starts with a /. var pathname = new URL(error.source).pathname.slice(1); requestFileSourceArgs.pathSuffix = pathname; requestFileSourceArgs.lineNumber = error.stackTrace && error.stackTrace[0] ? error.stackTrace[0].lineNumber : 0; break; default: assertNotReached(); } this.requestFileSource(requestFileSourceArgs); } else { this.onFileSourceResponse_(null); } }, /** * Associate an error with the overlay. This will set the error for the * overlay, and, if possible, will populate the code section of the overlay * with the relevant file, load the stack trace, and generate links for * opening devtools (the latter two only happen for runtime errors). * @param {Array<(RuntimeError|ManifestError)>} errors The error to show in * the overlay. * @param {string} extensionId The id of the extension. * @param {string} extensionName The name of the extension. */ setErrorsAndShowOverlay: function(errors, extensionId, extensionName) { document.querySelector( '#extension-error-overlay .extension-error-overlay-title'). textContent = extensionName; var errorsDiv = this.overlayDiv_.querySelector('.extension-error-list'); var extensionErrors = new extensions.ExtensionErrorList(errors, extensionId); errorsDiv.parentNode.replaceChild(extensionErrors, errorsDiv); extensionErrors.addEventListener('activeExtensionErrorChanged', function(e) { this.setActiveError_(e.detail); }.bind(this)); if (errors.length > 0) this.setActiveError_(errors[0]); this.setVisible(true); }, /** * Requests a file's source. * @param {chrome.developerPrivate.RequestFileSourceProperties} args The * arguments for the call. */ requestFileSource: function(args) { chrome.developerPrivate.requestFileSource( args, this.onFileSourceResponse_.bind(this)); }, /** * Set the code to be displayed in the code portion of the overlay. * @see ExtensionErrorOverlay.requestFileSourceResponse(). * @param {?chrome.developerPrivate.RequestFileSourceResponse} response The * response from the request file source call, which will be shown as * code. If |response| is null, then a "Could not display code" message * will be displayed instead. */ onFileSourceResponse_: function(response) { this.codeDiv_.populate( response, // ExtensionCode can handle a null response. loadTimeData.getString('extensionErrorOverlayNoCodeToDisplay')); this.setVisible(true); }, }; // Export return { ExtensionErrorOverlay: ExtensionErrorOverlay }; }); // // 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('extensions', function() { /** * @constructor * @extends {cr.ui.FocusManager} */ function ExtensionFocusManager() {} cr.addSingletonGetter(ExtensionFocusManager); ExtensionFocusManager.prototype = { __proto__: cr.ui.FocusManager.prototype, /** @override */ getFocusParent: function() { var overlay = extensions.ExtensionSettings.getCurrentOverlay(); return overlay || $('extension-settings'); }, }; return { ExtensionFocusManager: ExtensionFocusManager, }; }); // // 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. cr.define('extensions', function() { /** * @param {!Element} root * @param {?Element} boundary * @constructor * @extends {cr.ui.FocusRow} */ function FocusRow(root, boundary) { cr.ui.FocusRow.call(this, root, boundary); } FocusRow.prototype = { __proto__: cr.ui.FocusRow.prototype, /** @override */ makeActive: function(active) { cr.ui.FocusRow.prototype.makeActive.call(this, active); // Only highlight if the row has focus. this.root.classList.toggle('extension-highlight', active && this.root.contains(document.activeElement)); }, }; return {FocusRow: FocusRow}; }); // // 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 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('extensions', function() { 'use strict'; /** * Clone a template within the extension error template collection. * @param {string} templateName The class name of the template to clone. * @return {HTMLElement} The clone of the template. */ function cloneTemplate(templateName) { return /** @type {HTMLElement} */($('template-collection-extension-error'). querySelector('.' + templateName).cloneNode(true)); } /** * Checks that an Extension ID follows the proper format (i.e., is 32 * characters long, is lowercase, and contains letters in the range [a, p]). * @param {string} id The Extension ID to test. * @return {boolean} Whether or not the ID is valid. */ function idIsValid(id) { return /^[a-p]{32}$/.test(id); } /** * @param {!Array<(ManifestError|RuntimeError)>} errors * @param {number} id * @return {number} The index of the error with |id|, or -1 if not found. */ function findErrorById(errors, id) { for (var i = 0; i < errors.length; ++i) { if (errors[i].id == id) return i; } return -1; } /** * Creates a new ExtensionError HTMLElement; this is used to show a * notification to the user when an error is caused by an extension. * @param {(RuntimeError|ManifestError)} error The error the element should * represent. * @constructor * @extends {HTMLElement} */ function ExtensionError(error) { var div = cloneTemplate('extension-error-metadata'); div.__proto__ = ExtensionError.prototype; div.decorate(error); return div; } ExtensionError.prototype = { __proto__: HTMLElement.prototype, /** * @param {(RuntimeError|ManifestError)} error The error the element should * represent. * @private */ decorate: function(error) { /** * The backing error. * @type {(ManifestError|RuntimeError)} */ this.error = error; var iconAltTextKey = 'extensionLogLevelWarn'; // Add an additional class for the severity level. if (error.type == chrome.developerPrivate.ErrorType.RUNTIME) { switch (error.severity) { case chrome.developerPrivate.ErrorLevel.LOG: this.classList.add('extension-error-severity-info'); iconAltTextKey = 'extensionLogLevelInfo'; break; case chrome.developerPrivate.ErrorLevel.WARN: this.classList.add('extension-error-severity-warning'); break; case chrome.developerPrivate.ErrorLevel.ERROR: this.classList.add('extension-error-severity-fatal'); iconAltTextKey = 'extensionLogLevelError'; break; default: assertNotReached(); } } else { // We classify manifest errors as "warnings". this.classList.add('extension-error-severity-warning'); } var iconNode = document.createElement('img'); iconNode.className = 'extension-error-icon'; iconNode.alt = loadTimeData.getString(iconAltTextKey); this.insertBefore(iconNode, this.firstChild); var messageSpan = this.querySelector('.extension-error-message'); messageSpan.textContent = error.message; var deleteButton = this.querySelector('.error-delete-button'); deleteButton.addEventListener('click', function(e) { this.dispatchEvent( new CustomEvent('deleteExtensionError', {bubbles: true, detail: this.error})); }.bind(this)); this.addEventListener('click', function(e) { if (e.target != deleteButton) this.requestActive_(); }.bind(this)); this.addEventListener('keydown', function(e) { if (e.key == 'Enter' && e.target != deleteButton) this.requestActive_(); }); }, /** * Bubble up an event to request to become active. * @private */ requestActive_: function() { this.dispatchEvent( new CustomEvent('highlightExtensionError', {bubbles: true, detail: this.error})); }, }; /** * A variable length list of runtime or manifest errors for a given extension. * @param {Array<(RuntimeError|ManifestError)>} errors The list of extension * errors with which to populate the list. * @param {string} extensionId The id of the extension. * @constructor * @extends {HTMLDivElement} */ function ExtensionErrorList(errors, extensionId) { var div = cloneTemplate('extension-error-list'); div.__proto__ = ExtensionErrorList.prototype; div.extensionId_ = extensionId; div.decorate(errors); return div; } /** * @param {!Element} root * @param {?Element} boundary * @constructor * @extends {cr.ui.FocusRow} */ ExtensionErrorList.FocusRow = function(root, boundary) { cr.ui.FocusRow.call(this, root, boundary); this.addItem('message', '.extension-error-message'); this.addItem('delete', '.error-delete-button'); }; ExtensionErrorList.FocusRow.prototype = { __proto__: cr.ui.FocusRow.prototype, }; ExtensionErrorList.prototype = { __proto__: HTMLDivElement.prototype, /** * Initializes the extension error list. * @param {Array<(RuntimeError|ManifestError)>} errors The list of errors. */ decorate: function(errors) { /** @private {!Array<(ManifestError|RuntimeError)>} */ this.errors_ = []; /** @private {!cr.ui.FocusGrid} */ this.focusGrid_ = new cr.ui.FocusGrid(); /** @private {Element} */ this.listContents_ = this.querySelector('.extension-error-list-contents'); errors.forEach(this.addError_, this); this.focusGrid_.ensureRowActive(); this.addEventListener('highlightExtensionError', function(e) { this.setActiveErrorNode_(e.target); }); this.addEventListener('deleteExtensionError', function(e) { this.removeError_(e.detail); }); this.querySelector('#extension-error-list-clear').addEventListener( 'click', function(e) { this.clear(true); }.bind(this)); /** * The callback for the extension changed event. * @private {function(chrome.developerPrivate.EventData):void} */ this.onItemStateChangedListener_ = function(data) { var type = chrome.developerPrivate.EventType; if ((data.event_type == type.ERRORS_REMOVED || data.event_type == type.ERROR_ADDED) && data.extensionInfo.id == this.extensionId_) { var newErrors = data.extensionInfo.runtimeErrors.concat( data.extensionInfo.manifestErrors); this.updateErrors_(newErrors); } }.bind(this); chrome.developerPrivate.onItemStateChanged.addListener( this.onItemStateChangedListener_); /** * The active error element in the list. * @private {?} */ this.activeError_ = null; this.setActiveError(0); }, /** * Adds an error to the list. * @param {(RuntimeError|ManifestError)} error The error to add. * @private */ addError_: function(error) { this.querySelector('#no-errors-span').hidden = true; this.errors_.push(error); var extensionError = new ExtensionError(error); this.listContents_.appendChild(extensionError); this.focusGrid_.addRow( new ExtensionErrorList.FocusRow(extensionError, this.listContents_)); }, /** * Removes an error from the list. * @param {(RuntimeError|ManifestError)} error The error to remove. * @private */ removeError_: function(error) { var index = 0; for (; index < this.errors_.length; ++index) { if (this.errors_[index].id == error.id) break; } assert(index != this.errors_.length); var errorList = this.querySelector('.extension-error-list-contents'); var wasActive = this.activeError_ && this.activeError_.error.id == error.id; this.errors_.splice(index, 1); var listElement = errorList.children[index]; var focusRow = this.focusGrid_.getRowForRoot(listElement); this.focusGrid_.removeRow(focusRow); this.focusGrid_.ensureRowActive(); focusRow.destroy(); // TODO(dbeam): in a world where this UI is actually used, we should // probably move the focus before removing |listElement|. listElement.parentNode.removeChild(listElement); if (wasActive) { index = Math.min(index, this.errors_.length - 1); this.setActiveError(index); // Gracefully handles the -1 case. } chrome.developerPrivate.deleteExtensionErrors({ extensionId: error.extensionId, errorIds: [error.id] }); if (this.errors_.length == 0) this.querySelector('#no-errors-span').hidden = false; }, /** * Updates the list of errors. * @param {!Array<(ManifestError|RuntimeError)>} newErrors The new list of * errors. * @private */ updateErrors_: function(newErrors) { this.errors_.forEach(function(error) { if (findErrorById(newErrors, error.id) == -1) this.removeError_(error); }, this); newErrors.forEach(function(error) { var index = findErrorById(this.errors_, error.id); if (index == -1) this.addError_(error); else this.errors_[index] = error; // Update the existing reference. }, this); }, /** * Called when the list is being removed. */ onRemoved: function() { chrome.developerPrivate.onItemStateChanged.removeListener( this.onItemStateChangedListener_); this.clear(false); }, /** * Sets the active error in the list. * @param {number} index The index to set to be active. */ setActiveError: function(index) { var errorList = this.querySelector('.extension-error-list-contents'); var item = errorList.children[index]; this.setActiveErrorNode_( item ? item.querySelector('.extension-error-metadata') : null); var node = null; if (index >= 0 && index < errorList.children.length) { node = errorList.children[index].querySelector( '.extension-error-metadata'); } this.setActiveErrorNode_(node); }, /** * Clears the list of all errors. * @param {boolean} deleteErrors Whether or not the errors should be deleted * on the backend. */ clear: function(deleteErrors) { if (this.errors_.length == 0) return; if (deleteErrors) { var ids = this.errors_.map(function(error) { return error.id; }); chrome.developerPrivate.deleteExtensionErrors({ extensionId: this.extensionId_, errorIds: ids }); } this.setActiveErrorNode_(null); this.errors_.length = 0; var errorList = this.querySelector('.extension-error-list-contents'); while (errorList.firstChild) errorList.removeChild(errorList.firstChild); }, /** * Sets the active error in the list. * @param {?} node The error to make active. * @private */ setActiveErrorNode_: function(node) { if (this.activeError_) this.activeError_.classList.remove('extension-error-active'); if (node) node.classList.add('extension-error-active'); this.activeError_ = node; this.dispatchEvent( new CustomEvent('activeExtensionErrorChanged', {bubbles: true, detail: node ? node.error : null})); }, }; return { ExtensionErrorList: ExtensionErrorList }; }); cr.define('extensions', function() { 'use strict'; var ExtensionType = chrome.developerPrivate.ExtensionType; /** * @param {string} name The name of the template to clone. * @return {!Element} The freshly cloned template. */ function cloneTemplate(name) { var node = $('templates').querySelector('.' + name).cloneNode(true); return assertInstanceof(node, Element); } /** * @extends {HTMLElement} * @constructor */ function ExtensionWrapper() { var wrapper = cloneTemplate('extension-list-item-wrapper'); wrapper.__proto__ = ExtensionWrapper.prototype; wrapper.initialize(); return wrapper; } ExtensionWrapper.prototype = { __proto__: HTMLElement.prototype, initialize: function() { var boundary = $('extension-settings-list'); /** @private {!extensions.FocusRow} */ this.focusRow_ = new extensions.FocusRow(this, boundary); }, /** @return {!cr.ui.FocusRow} */ getFocusRow: function() { return this.focusRow_; }, /** * Add an item to the focus row and listen for |eventType| events. * @param {string} focusType A tag used to identify equivalent elements when * changing focus between rows. * @param {string} query A query to select the element to set up. * @param {string=} opt_eventType The type of event to listen to. * @param {function(Event)=} opt_handler The function that should be called * by the event. * @private */ setupColumn: function(focusType, query, opt_eventType, opt_handler) { assert(this.focusRow_.addItem(focusType, query)); if (opt_eventType) { assert(opt_handler); this.querySelector(query).addEventListener(opt_eventType, opt_handler); } }, }; var ExtensionCommandsOverlay = extensions.ExtensionCommandsOverlay; /** * Compares two extensions for the order they should appear in the list. * @param {chrome.developerPrivate.ExtensionInfo} a The first extension. * @param {chrome.developerPrivate.ExtensionInfo} b The second extension. * returns {number} -1 if A comes before B, 1 if A comes after B, 0 if equal. */ function compareExtensions(a, b) { function compare(x, y) { return x < y ? -1 : (x > y ? 1 : 0); } function compareLocation(x, y) { if (x.location == y.location) return 0; if (x.location == chrome.developerPrivate.Location.UNPACKED) return -1; if (y.location == chrome.developerPrivate.Location.UNPACKED) return 1; return 0; } return compareLocation(a, b) || compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id); } /** @interface */ function ExtensionListDelegate() {} ExtensionListDelegate.prototype = { /** * Called when the number of extensions in the list has changed. */ onExtensionCountChanged: assertNotReached, }; /** * Creates a new list of extensions. * @param {extensions.ExtensionListDelegate} delegate * @constructor * @extends {HTMLDivElement} */ function ExtensionList(delegate) { var div = document.createElement('div'); div.__proto__ = ExtensionList.prototype; div.initialize(delegate); return div; } ExtensionList.prototype = { __proto__: HTMLDivElement.prototype, /** * Indicates whether an embedded options page that was navigated to through * the '?options=' URL query has been shown to the user. This is necessary * to prevent showExtensionNodes_ from opening the options more than once. * @type {boolean} * @private */ optionsShown_: false, /** @private {!cr.ui.FocusGrid} */ focusGrid_: new cr.ui.FocusGrid(), /** * Indicates whether an uninstall dialog is being shown to prevent multiple * dialogs from being displayed. * @private {boolean} */ uninstallIsShowing_: false, /** * Indicates whether a permissions prompt is showing. * @private {boolean} */ permissionsPromptIsShowing_: false, /** * Whether or not any initial navigation (like scrolling to an extension, * or opening an options page) has occurred. * @private {boolean} */ didInitialNavigation_: false, /** * Whether or not incognito mode is available. * @private {boolean} */ incognitoAvailable_: false, /** * Whether or not the app info dialog is enabled. * @private {boolean} */ enableAppInfoDialog_: false, /** * Initializes the list. * @param {!extensions.ExtensionListDelegate} delegate */ initialize: function(delegate) { /** @private {!Array} */ this.extensions_ = []; /** @private {!extensions.ExtensionListDelegate} */ this.delegate_ = delegate; this.resetLoadFinished(); chrome.developerPrivate.onItemStateChanged.addListener( function(eventData) { var EventType = chrome.developerPrivate.EventType; switch (eventData.event_type) { case EventType.VIEW_REGISTERED: case EventType.VIEW_UNREGISTERED: case EventType.INSTALLED: case EventType.LOADED: case EventType.UNLOADED: case EventType.ERROR_ADDED: case EventType.ERRORS_REMOVED: case EventType.PREFS_CHANGED: if (eventData.extensionInfo) { this.updateOrCreateWrapper_(eventData.extensionInfo); this.focusGrid_.ensureRowActive(); } break; case EventType.UNINSTALLED: var index = this.getIndexOfExtension_(eventData.item_id); this.extensions_.splice(index, 1); this.removeWrapper_(getRequiredElement(eventData.item_id)); break; default: assertNotReached(); } if (eventData.event_type == EventType.UNLOADED) this.hideEmbeddedExtensionOptions_(eventData.item_id); if (eventData.event_type == EventType.INSTALLED || eventData.event_type == EventType.UNINSTALLED) { this.delegate_.onExtensionCountChanged(); } if (eventData.event_type == EventType.LOADED || eventData.event_type == EventType.UNLOADED || eventData.event_type == EventType.PREFS_CHANGED || eventData.event_type == EventType.UNINSTALLED) { // We update the commands overlay whenever an extension is added or // removed (other updates wouldn't affect command-ly things). We // need both UNLOADED and UNINSTALLED since the UNLOADED event results // in an extension losing active keybindings, and UNINSTALLED can // result in the "Keyboard shortcuts" link being removed. ExtensionCommandsOverlay.updateExtensionsData(this.extensions_); } }.bind(this)); }, /** * Resets the |loadFinished| promise so that it can be used again; this * is useful if the page updates and tests need to wait for it to finish. */ resetLoadFinished: function() { /** * |loadFinished| should be used for testing purposes and will be * fulfilled when this list has finished loading the first time. * @type {Promise} * */ this.loadFinished = new Promise(function(resolve, reject) { /** @private {function(?)} */ this.resolveLoadFinished_ = resolve; }.bind(this)); }, /** * Updates the extensions on the page. * @param {boolean} incognitoAvailable Whether or not incognito is allowed. * @param {boolean} enableAppInfoDialog Whether or not the app info dialog * is enabled. * @return {Promise} A promise that is resolved once the extensions data is * fully updated. */ updateExtensionsData: function(incognitoAvailable, enableAppInfoDialog) { // If we start to need more information about the extension configuration, // consider passing in the full object from the ExtensionSettings. this.incognitoAvailable_ = incognitoAvailable; this.enableAppInfoDialog_ = enableAppInfoDialog; /** @private {Promise} */ this.extensionsUpdated_ = new Promise(function(resolve, reject) { chrome.developerPrivate.getExtensionsInfo( {includeDisabled: true, includeTerminated: true}, function(extensions) { // Sort in order of unpacked vs. packed, followed by name, followed by // id. extensions.sort(compareExtensions); this.extensions_ = extensions; this.showExtensionNodes_(); // We keep the commands overlay's extension info in sync, so that we // don't duplicate the same querying logic there. ExtensionCommandsOverlay.updateExtensionsData(this.extensions_); resolve(); // |resolve| is async so it's necessary to use |then| here in order to // do work after other |then|s have finished. This is important so // elements are visible when these updates happen. this.extensionsUpdated_.then(function() { this.onUpdateFinished_(); this.resolveLoadFinished_(); }.bind(this)); }.bind(this)); }.bind(this)); return this.extensionsUpdated_; }, /** * Updates elements that need to be visible in order to update properly. * @private */ onUpdateFinished_: function() { // Cannot focus or highlight a extension if there are none, and we should // only scroll to a particular extension or open the options page once. if (this.extensions_.length == 0 || this.didInitialNavigation_) return; this.didInitialNavigation_ = true; assert(!this.hidden); assert(!this.parentElement.hidden); var idToHighlight = this.getIdQueryParam_(); if (idToHighlight) { var wrapper = $(idToHighlight); if (wrapper) { this.scrollToWrapper_(idToHighlight); var focusRow = wrapper.getFocusRow(); (focusRow.getFirstFocusable('enabled') || focusRow.getFirstFocusable('remove-enterprise') || focusRow.getFirstFocusable('website') || focusRow.getFirstFocusable('details')).focus(); } } var idToOpenOptions = this.getOptionsQueryParam_(); if (idToOpenOptions && $(idToOpenOptions)) this.showEmbeddedExtensionOptions_(idToOpenOptions, true); }, /** @return {number} The number of extensions being displayed. */ getNumExtensions: function() { return this.extensions_.length; }, /** * @param {string} id The id of the extension. * @return {number} The index of the extension with the given id. * @private */ getIndexOfExtension_: function(id) { for (var i = 0; i < this.extensions_.length; ++i) { if (this.extensions_[i].id == id) return i; } return -1; }, getIdQueryParam_: function() { return parseQueryParams(document.location)['id']; }, getOptionsQueryParam_: function() { return parseQueryParams(document.location)['options']; }, /** * Creates or updates all extension items from scratch. * @private */ showExtensionNodes_: function() { // Any node that is not updated will be removed. var seenIds = []; // Iterate over the extension data and add each item to the list. this.extensions_.forEach(function(extension) { seenIds.push(extension.id); this.updateOrCreateWrapper_(extension); }, this); this.focusGrid_.ensureRowActive(); // Remove extensions that are no longer installed. var wrappers = document.querySelectorAll( '.extension-list-item-wrapper[id]'); Array.prototype.forEach.call(wrappers, function(wrapper) { if (seenIds.indexOf(wrapper.id) < 0) this.removeWrapper_(wrapper); }, this); }, /** * Removes the wrapper from the DOM and updates the focused element if * needed. * @param {!Element} wrapper * @private */ removeWrapper_: function(wrapper) { // If focus is in the wrapper about to be removed, move it first. This // happens when clicking the trash can to remove an extension. if (wrapper.contains(document.activeElement)) { var wrappers = document.querySelectorAll( '.extension-list-item-wrapper[id]'); var index = Array.prototype.indexOf.call(wrappers, wrapper); assert(index != -1); var focusableWrapper = wrappers[index + 1] || wrappers[index - 1]; if (focusableWrapper) { var newFocusRow = focusableWrapper.getFocusRow(); newFocusRow.getEquivalentElement(document.activeElement).focus(); } } var focusRow = wrapper.getFocusRow(); this.focusGrid_.removeRow(focusRow); this.focusGrid_.ensureRowActive(); focusRow.destroy(); wrapper.parentNode.removeChild(wrapper); }, /** * Scrolls the page down to the extension node with the given id. * @param {string} extensionId The id of the extension to scroll to. * @private */ scrollToWrapper_: function(extensionId) { // Scroll offset should be calculated slightly higher than the actual // offset of the element being scrolled to, so that it ends up not all // the way at the top. That way it is clear that there are more elements // above the element being scrolled to. var wrapper = $(extensionId); var scrollFudge = 1.2; var scrollTop = wrapper.offsetTop - scrollFudge * wrapper.clientHeight; setScrollTopForDocument(document, scrollTop); }, /** * Synthesizes and initializes an HTML element for the extension metadata * given in |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension A dictionary * of extension metadata. * @param {?Element} nextWrapper The newly created wrapper will be inserted * before |nextWrapper| if non-null (else it will be appended to the * wrapper list). * @private */ createWrapper_: function(extension, nextWrapper) { var wrapper = new ExtensionWrapper; wrapper.id = extension.id; // The 'Permissions' link. wrapper.setupColumn('details', '.permissions-link', 'click', function(e) { if (!this.permissionsPromptIsShowing_) { chrome.developerPrivate.showPermissionsDialog(extension.id, function() { this.permissionsPromptIsShowing_ = false; }.bind(this)); this.permissionsPromptIsShowing_ = true; } e.preventDefault(); }); wrapper.setupColumn('options', '.options-button', 'click', function(e) { this.showEmbeddedExtensionOptions_(extension.id, false); e.preventDefault(); }.bind(this)); // The 'Options' button or link, depending on its behaviour. // Set an href to get the correct mouse-over appearance (link, // footer) - but the actual link opening is done through developerPrivate // API with a preventDefault(). wrapper.querySelector('.options-link').href = extension.optionsPage ? extension.optionsPage.url : ''; wrapper.setupColumn('options', '.options-link', 'click', function(e) { chrome.developerPrivate.showOptions(extension.id); e.preventDefault(); }); // The 'View in Web Store/View Web Site' link. wrapper.setupColumn('website', '.site-link'); // The 'Launch' link. wrapper.setupColumn('launch', '.launch-link', 'click', function(e) { chrome.management.launchApp(extension.id); }); // The 'Reload' link. wrapper.setupColumn('localReload', '.reload-link', 'click', function(e) { chrome.developerPrivate.reload(extension.id, {failQuietly: true}); }); wrapper.setupColumn('errors', '.errors-link', 'click', function(e) { var extensionId = extension.id; assert(this.extensions_.length > 0); var newEx = this.extensions_.filter(function(e) { return e.id == extensionId; })[0]; var errors = newEx.manifestErrors.concat(newEx.runtimeErrors); extensions.ExtensionErrorOverlay.getInstance().setErrorsAndShowOverlay( errors, extensionId, newEx.name); }.bind(this)); wrapper.setupColumn('suspiciousLearnMore', '.suspicious-install-message .learn-more-link'); // The path, if provided by unpacked extension. wrapper.setupColumn('loadPath', '.load-path a:first-of-type', 'click', function(e) { chrome.developerPrivate.showPath(extension.id); e.preventDefault(); }); // The 'allow in incognito' checkbox. wrapper.setupColumn('incognito', '.incognito-control input', 'change', function(e) { var butterBar = wrapper.querySelector('.butter-bar'); var checked = e.target.checked; butterBar.hidden = !checked || extension.type == ExtensionType.HOSTED_APP; chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, incognitoAccess: e.target.checked }); }.bind(this)); // The 'collect errors' checkbox. This should only be visible if the // error console is enabled - we can detect this by the existence of the // |errorCollectionEnabled| property. wrapper.setupColumn('collectErrors', '.error-collection-control input', 'change', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, errorCollection: e.target.checked }); }); // The 'allow on all urls' checkbox. This should only be visible if // active script restrictions are enabled. If they are not enabled, no // extensions should want all urls. wrapper.setupColumn('allUrls', '.all-urls-control input', 'click', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, runOnAllUrls: e.target.checked }); }); // The 'allow file:// access' checkbox. wrapper.setupColumn('localUrls', '.file-access-control input', 'click', function(e) { chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extension.id, fileAccess: e.target.checked }); }); // The 'Reload' terminated link. wrapper.setupColumn('terminatedReload', '.terminated-reload-link', 'click', function(e) { chrome.developerPrivate.reload(extension.id, {failQuietly: true}); }); // The 'Repair' corrupted link. wrapper.setupColumn('repair', '.corrupted-repair-button', 'click', function(e) { chrome.developerPrivate.repairExtension(extension.id); }); // The 'Enabled' checkbox. wrapper.setupColumn('enabled', '.enable-checkbox input', 'click', function(e) { var checked = e.target.checked; // TODO(devlin): What should we do if this fails? Ideally we want to // show some kind of error or feedback to the user if this fails. chrome.management.setEnabled(extension.id, checked); // This may seem counter-intuitive (to not set/clear the checkmark) // but this page will be updated asynchronously if the extension // becomes enabled/disabled. It also might not become enabled or // disabled, because the user might e.g. get prompted when enabling // and choose not to. e.preventDefault(); }); // 'Remove' button. var trash = cloneTemplate('trash'); trash.title = loadTimeData.getString('extensionUninstall'); wrapper.querySelector('.enable-controls').appendChild(trash); wrapper.setupColumn('remove-enterprise', '.trash', 'click', function(e) { trash.classList.add('open'); trash.classList.toggle('mouse-clicked', e.detail > 0); if (this.uninstallIsShowing_) return; this.uninstallIsShowing_ = true; chrome.management.uninstall(extension.id, {showConfirmDialog: true}, function() { // TODO(devlin): What should we do if the uninstall fails? this.uninstallIsShowing_ = false; if (trash.classList.contains('mouse-clicked')) trash.blur(); if (chrome.runtime.lastError) { // The uninstall failed (e.g. a cancel). Allow the trash to close. trash.classList.remove('open'); } else { // Leave the trash open if the uninstall succeded. Otherwise it can // half-close right before it's removed when the DOM is modified. } }.bind(this)); }.bind(this)); // Maintain the order that nodes should be in when creating as well as // when adding only one new wrapper. this.insertBefore(wrapper, nextWrapper); this.updateWrapper_(extension, wrapper); var nextRow = this.focusGrid_.getRowForRoot(nextWrapper); // May be null. this.focusGrid_.addRowBefore(wrapper.getFocusRow(), nextRow); }, /** * Updates an HTML element for the extension metadata given in |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension A dictionary of * extension metadata. * @param {!Element} wrapper The extension wrapper element to update. * @private */ updateWrapper_: function(extension, wrapper) { var isActive = extension.state == chrome.developerPrivate.ExtensionState.ENABLED; wrapper.classList.toggle('inactive-extension', !isActive); wrapper.classList.remove('controlled', 'may-not-remove'); if (extension.controlledInfo) { wrapper.classList.add('controlled'); } else if (!extension.userMayModify || extension.mustRemainInstalled || extension.dependentExtensions.length > 0) { wrapper.classList.add('may-not-remove'); } var item = wrapper.querySelector('.extension-list-item'); item.style.backgroundImage = 'url(' + extension.iconUrl + ')'; this.setText_(wrapper, '.extension-title', extension.name); this.setText_(wrapper, '.extension-version', extension.version); this.setText_(wrapper, '.location-text', extension.locationText || ''); this.setText_(wrapper, '.blacklist-text', extension.blacklistText || ''); this.setText_(wrapper, '.extension-description', extension.description); // The 'allow in incognito' checkbox. this.updateVisibility_(wrapper, '.incognito-control', isActive && this.incognitoAvailable_, function(item) { var incognito = item.querySelector('input'); incognito.disabled = !extension.incognitoAccess.isEnabled; incognito.checked = extension.incognitoAccess.isActive; }); var showButterBar = isActive && extension.incognitoAccess.isActive && extension.type != ExtensionType.HOSTED_APP; // The 'allow in incognito' butter bar. this.updateVisibility_(wrapper, '.butter-bar', showButterBar); // The 'collect errors' checkbox. This should only be visible if the // error console is enabled - we can detect this by the existence of the // |errorCollectionEnabled| property. this.updateVisibility_( wrapper, '.error-collection-control', isActive && extension.errorCollection.isEnabled, function(item) { item.querySelector('input').checked = extension.errorCollection.isActive; }); // The 'allow on all urls' checkbox. This should only be visible if // active script restrictions are enabled. If they are not enabled, no // extensions should want all urls. this.updateVisibility_( wrapper, '.all-urls-control', isActive && extension.runOnAllUrls.isEnabled, function(item) { item.querySelector('input').checked = extension.runOnAllUrls.isActive; }); // The 'allow file:// access' checkbox. this.updateVisibility_(wrapper, '.file-access-control', isActive && extension.fileAccess.isEnabled, function(item) { item.querySelector('input').checked = extension.fileAccess.isActive; }); // The 'Options' button or link, depending on its behaviour. var optionsEnabled = isActive && !!extension.optionsPage; this.updateVisibility_(wrapper, '.options-link', optionsEnabled && extension.optionsPage.openInTab); this.updateVisibility_(wrapper, '.options-button', optionsEnabled && !extension.optionsPage.openInTab); // The 'View in Web Store/View Web Site' link. var siteLinkEnabled = !!extension.homePage.url && !this.enableAppInfoDialog_; this.updateVisibility_(wrapper, '.site-link', siteLinkEnabled, function(item) { item.href = extension.homePage.url; item.textContent = loadTimeData.getString( extension.homePage.specified ? 'extensionSettingsVisitWebsite' : 'extensionSettingsVisitWebStore'); }); var isUnpacked = extension.location == chrome.developerPrivate.Location.UNPACKED; // The 'Reload' link. this.updateVisibility_(wrapper, '.reload-link', isActive && isUnpacked); // The 'Launch' link. this.updateVisibility_( wrapper, '.launch-link', isUnpacked && extension.type == ExtensionType.PLATFORM_APP && isActive); // The 'Errors' link. var hasErrors = extension.runtimeErrors.length > 0 || extension.manifestErrors.length > 0; this.updateVisibility_(wrapper, '.errors-link', hasErrors, function(item) { var Level = chrome.developerPrivate.ErrorLevel; var map = {}; map[Level.LOG] = {weight: 0, name: 'extension-error-info-icon'}; map[Level.WARN] = {weight: 1, name: 'extension-error-warning-icon'}; map[Level.ERROR] = {weight: 2, name: 'extension-error-fatal-icon'}; // Find the highest severity of all the errors; manifest errors all have // a 'warning' level severity. var highestSeverity = extension.runtimeErrors.reduce( function(prev, error) { return map[error.severity].weight > map[prev].weight ? error.severity : prev; }, extension.manifestErrors.length ? Level.WARN : Level.LOG); // Adjust the class on the icon. var icon = item.querySelector('.extension-error-icon'); // TODO(hcarmona): Populate alt text with a proper description since // this icon conveys the severity of the error. (info, warning, fatal). icon.alt = ''; icon.className = 'extension-error-icon'; // Remove other classes. icon.classList.add(map[highestSeverity].name); }); // The 'Reload' terminated link. var isTerminated = extension.state == chrome.developerPrivate.ExtensionState.TERMINATED; this.updateVisibility_(wrapper, '.terminated-reload-link', isTerminated); // The 'Repair' corrupted link. var canRepair = !isTerminated && extension.disableReasons.corruptInstall && extension.location == chrome.developerPrivate.Location.FROM_STORE; this.updateVisibility_(wrapper, '.corrupted-repair-button', canRepair); // The 'Enabled' checkbox. var isOK = !isTerminated && !canRepair; this.updateVisibility_(wrapper, '.enable-checkbox', isOK, function(item) { var enableCheckboxDisabled = !extension.userMayModify || extension.disableReasons.suspiciousInstall || extension.disableReasons.corruptInstall || extension.disableReasons.updateRequired || extension.dependentExtensions.length > 0 || extension.state == chrome.developerPrivate.ExtensionState.BLACKLISTED; item.querySelector('input').disabled = enableCheckboxDisabled; item.querySelector('input').checked = isActive; }); // Indicator for extensions controlled by policy. var controlNode = wrapper.querySelector('.enable-controls'); var indicator = controlNode.querySelector('.controlled-extension-indicator'); var needsIndicator = isOK && extension.controlledInfo; if (needsIndicator && !indicator) { indicator = new cr.ui.ControlledIndicator(); indicator.classList.add('controlled-extension-indicator'); var ControllerType = chrome.developerPrivate.ControllerType; var controlledByStr = ''; switch (extension.controlledInfo.type) { case ControllerType.POLICY: controlledByStr = 'policy'; break; case ControllerType.CHILD_CUSTODIAN: controlledByStr = 'child-custodian'; break; case ControllerType.SUPERVISED_USER_CUSTODIAN: controlledByStr = 'supervised-user-custodian'; break; } indicator.setAttribute('controlled-by', controlledByStr); var text = extension.controlledInfo.text; indicator.setAttribute('text' + controlledByStr, text); indicator.image.setAttribute('aria-label', text); controlNode.appendChild(indicator); wrapper.setupColumn('remove-enterprise', '[controlled-by] div'); } else if (!needsIndicator && indicator) { controlNode.removeChild(indicator); } // Developer mode //////////////////////////////////////////////////////// // First we have the id. var idLabel = wrapper.querySelector('.extension-id'); idLabel.textContent = ' ' + extension.id; // Then the path, if provided by unpacked extension. this.updateVisibility_(wrapper, '.load-path', isUnpacked, function(item) { item.querySelector('a:first-of-type').textContent = ' ' + extension.prettifiedPath; }); // Then the 'managed, cannot uninstall/disable' message. // We would like to hide managed installed message since this // extension is disabled. var isRequired = !extension.userMayModify || extension.mustRemainInstalled; this.updateVisibility_(wrapper, '.managed-message', isRequired && !extension.disableReasons.updateRequired); // Then the 'This isn't from the webstore, looks suspicious' message. var isSuspicious = extension.disableReasons.suspiciousInstall; this.updateVisibility_(wrapper, '.suspicious-install-message', !isRequired && isSuspicious); // Then the 'This is a corrupt extension' message. this.updateVisibility_(wrapper, '.corrupt-install-message', !isRequired && extension.disableReasons.corruptInstall); // Then the 'An update required by enterprise policy' message. Note that // a force-installed extension might be disabled due to being outdated // as well. this.updateVisibility_(wrapper, '.update-required-message', extension.disableReasons.updateRequired); // The 'following extensions depend on this extension' list. var hasDependents = extension.dependentExtensions.length > 0; wrapper.classList.toggle('developer-extras', hasDependents); this.updateVisibility_(wrapper, '.dependent-extensions-message', hasDependents, function(item) { var dependentList = item.querySelector('ul'); dependentList.textContent = ''; extension.dependentExtensions.forEach(function(dependentExtension) { var depNode = cloneTemplate('dependent-list-item'); depNode.querySelector('.dep-extension-title').textContent = dependentExtension.name; depNode.querySelector('.dep-extension-id').textContent = dependentExtension.id; dependentList.appendChild(depNode); }, this); }.bind(this)); // The active views. this.updateVisibility_(wrapper, '.active-views', extension.views.length > 0, function(item) { var link = item.querySelector('a'); // Link needs to be an only child before the list is updated. while (link.nextElementSibling) item.removeChild(link.nextElementSibling); // Link needs to be cleaned up if it was used before. link.textContent = ''; if (link.clickHandler) link.removeEventListener('click', link.clickHandler); extension.views.forEach(function(view, i) { if (view.type == chrome.developerPrivate.ViewType.EXTENSION_DIALOG || view.type == chrome.developerPrivate.ViewType.EXTENSION_POPUP) { return; } var displayName; if (view.url.startsWith('chrome-extension://')) { var pathOffset = 'chrome-extension://'.length + 32 + 1; displayName = view.url.substring(pathOffset); if (displayName == '_generated_background_page.html') displayName = loadTimeData.getString('backgroundPage'); } else { displayName = view.url; } var label = displayName + (view.incognito ? ' ' + loadTimeData.getString('viewIncognito') : '') + (view.renderProcessId == -1 ? ' ' + loadTimeData.getString('viewInactive') : '') + (view.isIframe ? ' ' + loadTimeData.getString('viewIframe') : ''); link.textContent = label; link.clickHandler = function(e) { chrome.developerPrivate.openDevTools({ extensionId: extension.id, renderProcessId: view.renderProcessId, renderViewId: view.renderViewId, incognito: view.incognito }); }; link.addEventListener('click', link.clickHandler); if (i < extension.views.length - 1) { link = link.cloneNode(true); item.appendChild(link); } wrapper.setupColumn('activeView', '.active-views a:last-of-type'); }); }); // The extension warnings (describing runtime issues). this.updateVisibility_(wrapper, '.extension-warnings', extension.runtimeWarnings.length > 0, function(item) { var warningList = item.querySelector('ul'); warningList.textContent = ''; extension.runtimeWarnings.forEach(function(warning) { var li = document.createElement('li'); warningList.appendChild(li).innerText = warning; }); }); // Install warnings. this.updateVisibility_(wrapper, '.install-warnings', extension.installWarnings.length > 0, function(item) { var installWarningList = item.querySelector('ul'); installWarningList.textContent = ''; if (extension.installWarnings) { extension.installWarnings.forEach(function(warning) { var li = document.createElement('li'); li.innerText = warning; installWarningList.appendChild(li); }); } }); if (location.hash.substr(1) == extension.id) { // Scroll beneath the fixed header so that the extension is not // obscured. var topScroll = wrapper.offsetTop - $('page-header').offsetHeight; var pad = parseInt(window.getComputedStyle(wrapper).marginTop, 10); if (!isNaN(pad)) topScroll -= pad / 2; setScrollTopForDocument(document, topScroll); } }, /** * Updates an element's textContent. * @param {Node} node Ancestor of the element specified by |query|. * @param {string} query A query to select an element in |node|. * @param {string} textContent * @private */ setText_: function(node, query, textContent) { node.querySelector(query).textContent = textContent; }, /** * Updates an element's visibility and calls |shownCallback| if it is * visible. * @param {Node} node Ancestor of the element specified by |query|. * @param {string} query A query to select an element in |node|. * @param {boolean} visible Whether the element should be visible or not. * @param {function(Element)=} opt_shownCallback Callback if the element is * visible. The element passed in will be the element specified by * |query|. * @private */ updateVisibility_: function(node, query, visible, opt_shownCallback) { var element = assertInstanceof(node.querySelector(query), Element); element.hidden = !visible; if (visible && opt_shownCallback) opt_shownCallback(element); }, /** * Opens the extension options overlay for the extension with the given id. * @param {string} extensionId The id of extension whose options page should * be displayed. * @param {boolean} scroll Whether the page should scroll to the extension * @private */ showEmbeddedExtensionOptions_: function(extensionId, scroll) { if (this.optionsShown_) return; // Get the extension from the given id. var extension = this.extensions_.filter(function(extension) { return extension.state == chrome.developerPrivate.ExtensionState.ENABLED && extension.id == extensionId; })[0]; if (!extension) return; if (scroll) this.scrollToWrapper_(extensionId); // Add the options query string. Corner case: the 'options' query string // will clobber the 'id' query string if the options link is clicked when // 'id' is in the URL, or if both query strings are in the URL. window.history.replaceState({}, '', '/?options=' + extensionId); var overlay = extensions.ExtensionOptionsOverlay.getInstance(); var shownCallback = function() { // This overlay doesn't get focused automatically as // is created after the overlay is shown. if (cr.ui.FocusOutlineManager.forDocument(document).visible) overlay.setInitialFocus(); }; overlay.setExtensionAndShow(extensionId, extension.name, extension.iconUrl, shownCallback); this.optionsShown_ = true; var self = this; $('overlay').addEventListener('cancelOverlay', function f() { self.optionsShown_ = false; $('overlay').removeEventListener('cancelOverlay', f); // Remove the options query string. window.history.replaceState({}, '', '/'); }); // TODO(dbeam): why do we need to focus before and // after its showing animation? Makes very little sense to me. overlay.setInitialFocus(); }, /** * Hides the extension options overlay for the extension with id * |extensionId|. If there is an overlay showing for a different extension, * nothing happens. * @param {string} extensionId ID of the extension to hide. * @private */ hideEmbeddedExtensionOptions_: function(extensionId) { if (!this.optionsShown_) return; var overlay = extensions.ExtensionOptionsOverlay.getInstance(); if (overlay.getExtensionId() == extensionId) overlay.close(); }, /** * Updates or creates a wrapper for |extension|. * @param {!chrome.developerPrivate.ExtensionInfo} extension The information * about the extension to update. * @private */ updateOrCreateWrapper_: function(extension) { var currIndex = this.getIndexOfExtension_(extension.id); if (currIndex != -1) { // If there is a current version of the extension, update it with the // new version. this.extensions_[currIndex] = extension; } else { // If the extension isn't found, push it back and sort. Technically, we // could optimize by inserting it at the right location, but since this // only happens on extension install, it's not worth it. this.extensions_.push(extension); this.extensions_.sort(compareExtensions); } var wrapper = $(extension.id); if (wrapper) { this.updateWrapper_(extension, wrapper); } else { var nextExt = this.extensions_[this.extensions_.indexOf(extension) + 1]; this.createWrapper_(extension, nextExt ? $(nextExt.id) : null); } } }; return { ExtensionList: ExtensionList, ExtensionListDelegate: ExtensionListDelegate }; }); // // 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('extensions', function() { /** * PackExtensionOverlay class * Encapsulated handling of the 'Pack Extension' overlay page. * @constructor */ function PackExtensionOverlay() { } cr.addSingletonGetter(PackExtensionOverlay); PackExtensionOverlay.prototype = { /** * Initialize the page. */ initializePage: function() { var overlay = $('overlay'); cr.ui.overlay.setupOverlay(overlay); cr.ui.overlay.globalInitialization(); overlay.addEventListener('cancelOverlay', this.handleDismiss_.bind(this)); $('pack-extension-dismiss').addEventListener('click', function() { cr.dispatchSimpleEvent(overlay, 'cancelOverlay'); }); $('pack-extension-commit').addEventListener('click', this.handleCommit_.bind(this)); $('browse-extension-dir').addEventListener('click', this.handleBrowseExtensionDir_.bind(this)); $('browse-private-key').addEventListener('click', this.handleBrowsePrivateKey_.bind(this)); }, /** * Handles a click on the dismiss button. * @param {Event} e The click event. */ handleDismiss_: function(e) { extensions.ExtensionSettings.showOverlay(null); }, /** * Handles a click on the pack button. * @param {Event} e The click event. */ handleCommit_: function(e) { var extensionPath = $('extension-root-dir').value; var privateKeyPath = $('extension-private-key').value; chrome.developerPrivate.packDirectory( extensionPath, privateKeyPath, 0, this.onPackResponse_.bind(this)); }, /** * Utility function which asks the C++ to show a platform-specific file * select dialog, and set the value property of |node| to the selected path. * @param {chrome.developerPrivate.SelectType} selectType * The type of selection to use. * @param {chrome.developerPrivate.FileType} fileType * The type of file to select. * @param {HTMLInputElement} node The node to set the value of. * @private */ showFileDialog_: function(selectType, fileType, node) { chrome.developerPrivate.choosePath(selectType, fileType, function(path) { // Last error is set if the user canceled the dialog. if (!chrome.runtime.lastError && path) node.value = path; }); }, /** * Handles the showing of the extension directory browser. * @param {Event} e Change event. * @private */ handleBrowseExtensionDir_: function(e) { this.showFileDialog_( chrome.developerPrivate.SelectType.FOLDER, chrome.developerPrivate.FileType.LOAD, /** @type {HTMLInputElement} */ ($('extension-root-dir'))); }, /** * Handles the showing of the extension private key file. * @param {Event} e Change event. * @private */ handleBrowsePrivateKey_: function(e) { this.showFileDialog_( chrome.developerPrivate.SelectType.FILE, chrome.developerPrivate.FileType.PEM, /** @type {HTMLInputElement} */ ($('extension-private-key'))); }, /** * Handles a response from a packDirectory call. * @param {chrome.developerPrivate.PackDirectoryResponse} response The * response of the pack call. * @private */ onPackResponse_: function(response) { /** @type {string} */ var alertTitle; /** @type {string} */ var alertOk; /** @type {string} */ var alertCancel; /** @type {function()} */ var alertOkCallback; /** @type {function()} */ var alertCancelCallback; var closeAlert = function() { extensions.ExtensionSettings.showOverlay(null); }; switch (response.status) { case chrome.developerPrivate.PackStatus.SUCCESS: alertTitle = loadTimeData.getString('packExtensionOverlay'); alertOk = loadTimeData.getString('ok'); alertOkCallback = closeAlert; // No 'Cancel' option. break; case chrome.developerPrivate.PackStatus.WARNING: alertTitle = loadTimeData.getString('packExtensionWarningTitle'); alertOk = loadTimeData.getString('packExtensionProceedAnyway'); alertCancel = loadTimeData.getString('cancel'); alertOkCallback = function() { chrome.developerPrivate.packDirectory( response.item_path, response.pem_path, response.override_flags, this.onPackResponse_.bind(this)); closeAlert(); }.bind(this); alertCancelCallback = closeAlert; break; case chrome.developerPrivate.PackStatus.ERROR: alertTitle = loadTimeData.getString('packExtensionErrorTitle'); alertOk = loadTimeData.getString('ok'); alertOkCallback = function() { extensions.ExtensionSettings.showOverlay( $('pack-extension-overlay')); }; // No 'Cancel' option. break; default: assertNotReached(); return; } alertOverlay.setValues(alertTitle, response.message, alertOk, alertCancel, alertOkCallback, alertCancelCallback); extensions.ExtensionSettings.showOverlay($('alertOverlay')); }, }; // Export return { PackExtensionOverlay: PackExtensionOverlay }; }); // // 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('extensions', function() { 'use strict'; /** * Construct an ExtensionLoadError around the given |div|. * @param {HTMLDivElement} div The HTML div for the extension load error. * @constructor * @extends {HTMLDivElement} */ function ExtensionLoadError(div) { div.__proto__ = ExtensionLoadError.prototype; div.init(); return div; } /** * Construct a Failure. * @param {string} filePath The path to the unpacked extension. * @param {string} error The reason the extension failed to load. * @param {ExtensionHighlight} manifest Three 'highlight' strings in * |manifest| represent three portions of the file's content to display - * the portion which is most relevant and should be emphasized * (highlight), and the parts both before and after this portion. These * may be empty. * @param {HTMLLIElement} listElement The HTML element used for displaying the * failure path for the additional failures UI. * @constructor * @extends {HTMLDivElement} */ function Failure(filePath, error, manifest, listElement) { this.path = filePath; this.error = error; this.manifest = manifest; this.listElement = listElement; } ExtensionLoadError.prototype = { __proto__: HTMLDivElement.prototype, /** * Initialize the ExtensionLoadError div. */ init: function() { /** * The element which displays the path of the extension. * @type {HTMLElement} * @private */ this.path_ = /** @type {HTMLElement} */( this.querySelector('#extension-load-error-path')); /** * The element which displays the reason the extension failed to load. * @type {HTMLElement} * @private */ this.reason_ = /** @type {HTMLElement} */( this.querySelector('#extension-load-error-reason')); /** * The element which displays the manifest code. * @type {extensions.ExtensionCode} * @private */ this.manifest_ = new extensions.ExtensionCode( this.querySelector('#extension-load-error-manifest')); /** * The element which displays information about additional errors. * @type {HTMLElement} * @private */ this.additional_ = /** @type {HTMLUListElement} */( this.querySelector('#extension-load-error-additional')); this.additional_.list = this.additional_.getElementsByTagName('ul')[0]; /** * An array of Failures for keeping track of multiple active failures. * @type {Array} * @private */ this.failures_ = []; this.querySelector('#extension-load-error-retry-button').addEventListener( 'click', function(e) { chrome.send('extensionLoaderRetry'); this.remove_(); }.bind(this)); this.querySelector('#extension-load-error-give-up-button'). addEventListener('click', function(e) { chrome.send('extensionLoaderIgnoreFailure'); this.remove_(); }.bind(this)); chrome.send('extensionLoaderDisplayFailures'); }, /** * Add a failure to failures_ array. If there is already a displayed * failure, display the additional failures element. * @param {Array} failures Array of failures containing paths, * errors, and manifests. * @private */ add_: function(failures) { // If a failure is already being displayed, unhide the last item. if (this.failures_.length > 0) this.failures_[this.failures_.length - 1].listElement.hidden = false; failures.forEach(function(failure) { var listItem = /** @type {HTMLLIElement} */( document.createElement('li')); listItem.textContent = failure.path; this.additional_.list.appendChild(listItem); this.failures_.push(new Failure(failure.path, failure.error, failure.manifest, listItem)); }.bind(this)); // Hide the last item because the UI is displaying its information. this.failures_[this.failures_.length - 1].listElement.hidden = true; this.show_(); }, /** * Remove a failure from |failures_| array. If this was the last failure, * hide the error UI. If this was the last additional failure, hide * the additional failures UI. * @private */ remove_: function() { this.additional_.list.removeChild( this.failures_[this.failures_.length - 1].listElement); this.failures_.pop(); if (this.failures_.length > 0) { this.failures_[this.failures_.length - 1].listElement.hidden = true; this.show_(); } else { this.hidden = true; } }, /** * Display the load error to the user. The last failure gets its manifest * and error displayed, while additional failures have their path names * displayed in the additional failures element. * @private */ show_: function() { assert(this.failures_.length >= 1); var failure = this.failures_[this.failures_.length - 1]; this.path_.textContent = failure.path; this.reason_.textContent = failure.error; failure.manifest.message = failure.error; this.manifest_.populate( failure.manifest, loadTimeData.getString('extensionLoadCouldNotLoadManifest')); this.hidden = false; this.manifest_.scrollToError(); this.additional_.hidden = this.failures_.length == 1; } }; /** * The ExtensionLoader is the class in charge of loading unpacked extensions. * @constructor */ function ExtensionLoader() { /** * The ExtensionLoadError to show any errors from loading an unpacked * extension. * @type {ExtensionLoadError} * @private */ this.loadError_ = new ExtensionLoadError( /** @type {HTMLDivElement} */($('extension-load-error'))); } cr.addSingletonGetter(ExtensionLoader); ExtensionLoader.prototype = { /** * Whether or not we are currently loading an unpacked extension. * @private {boolean} */ isLoading_: false, /** * Begin the sequence of loading an unpacked extension. If an error is * encountered, this object will get notified via notifyFailed(). */ loadUnpacked: function() { if (this.isLoading_) // Only one running load at a time. return; this.isLoading_ = true; chrome.developerPrivate.loadUnpacked({failQuietly: true}, function() { // Check lastError to avoid the log, but don't do anything with it - // error-handling is done on the C++ side. var lastError = chrome.runtime.lastError; this.isLoading_ = false; }.bind(this)); }, /** * Notify the ExtensionLoader that loading an unpacked extension failed. * Add the failure to failures_ and show the ExtensionLoadError. * @param {Array} failures Array of failures containing paths, * errors, and manifests. */ notifyFailed: function(failures) { this.loadError_.add_(failures); }, }; /** * A static forwarding function for ExtensionLoader.notifyFailed. * @param {Array} failures Array of failures containing paths, * errors, and manifests. * @see ExtensionLoader.notifyFailed */ ExtensionLoader.notifyLoadFailed = function(failures) { ExtensionLoader.getInstance().notifyFailed(failures); }; return { ExtensionLoader: ExtensionLoader }; }); // // 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 width of a scrollbar in logical pixels. function getScrollbarWidth() { // Create nested divs with scrollbars. var outer = document.createElement('div'); outer.style.width = '100px'; outer.style.overflow = 'scroll'; outer.style.visibility = 'hidden'; document.body.appendChild(outer); var inner = document.createElement('div'); inner.style.width = '101px'; outer.appendChild(inner); // The outer div's |clientWidth| and |offsetWidth| differ only by the width of // the vertical scrollbar. var scrollbarWidth = outer.offsetWidth - outer.clientWidth; outer.parentNode.removeChild(outer); return scrollbarWidth; } cr.define('extensions', function() { 'use strict'; /** * The ExtensionOptionsOverlay will show an extension's options page using * an element. * @constructor */ function ExtensionOptionsOverlay() {} cr.addSingletonGetter(ExtensionOptionsOverlay); ExtensionOptionsOverlay.prototype = { /** * The function that shows the given element in the overlay. * @type {?function(HTMLDivElement)} Function that receives the element to * show in the overlay. * @private */ showOverlay_: null, /** * The id of the extension that this options page display. * @type {string} * @private */ extensionId_: '', /** * Initialize the page. * @param {function(HTMLDivElement)} showOverlay The function to show or * hide the ExtensionOptionsOverlay; this should take a single parameter * which is either the overlay Div if the overlay should be displayed, * or null if the overlay should be hidden. */ initializePage: function(showOverlay) { var overlay = $('overlay'); cr.ui.overlay.setupOverlay(overlay); cr.ui.overlay.globalInitialization(); overlay.addEventListener('cancelOverlay', this.handleDismiss_.bind(this)); this.showOverlay_ = showOverlay; }, setInitialFocus: function() { this.getExtensionOptions_().focus(); }, /** * @return {?Element} * @private */ getExtensionOptions_: function() { return $('extension-options-overlay-guest').querySelector( 'extensionoptions'); }, /** * Handles a click on the close button. * @param {Event} event The click event. * @private */ handleDismiss_: function(event) { this.setVisible_(false); var extensionoptions = this.getExtensionOptions_(); if (extensionoptions) $('extension-options-overlay-guest').removeChild(extensionoptions); $('extension-options-overlay-icon').removeAttribute('src'); }, /** * Associate an extension with the overlay and display it. * @param {string} extensionId The id of the extension whose options page * should be displayed in the overlay. * @param {string} extensionName The name of the extension, which is used * as the header of the overlay. * @param {string} extensionIcon The URL of the extension's icon. * @param {function():void} shownCallback A function called when * showing completes. * @suppress {checkTypes} * TODO(vitalyp): remove the suppression after adding * chrome/renderer/resources/extensions/extension_options.js * to dependencies. */ setExtensionAndShow: function(extensionId, extensionName, extensionIcon, shownCallback) { var overlay = $('extension-options-overlay'); var overlayHeader = $('extension-options-overlay-header'); var overlayGuest = $('extension-options-overlay-guest'); var overlayStyle = window.getComputedStyle(overlay); $('extension-options-overlay-title').textContent = extensionName; $('extension-options-overlay-icon').src = extensionIcon; this.setVisible_(true); var extensionoptions = new window.ExtensionOptions(); extensionoptions.extension = extensionId; this.extensionId_ = extensionId; // The content's size needs to be restricted to the // bounds of the overlay window. The overlay gives a minWidth and // maxHeight, but the maxHeight does not include our header height (title // and close button), so we need to subtract that to get the maxHeight // for the extension options. var maxHeight = parseInt(overlayStyle.maxHeight, 10) - overlayHeader.offsetHeight; var minWidth = parseInt(overlayStyle.minWidth, 10); extensionoptions.onclose = function() { cr.dispatchSimpleEvent($('overlay'), 'cancelOverlay'); }.bind(this); // Track the current animation (used to grow/shrink the overlay content), // if any. Preferred size changes can fire more rapidly than the // animation speed, and multiple animations running at the same time has // undesirable effects. var animation = null; /** * Resize the overlay if the changes preferred size. * @param {{width: number, height: number}} evt */ extensionoptions.onpreferredsizechanged = function(evt) { var oldOverlayWidth = parseInt(overlayStyle.width, 10); var oldOverlayHeight = parseInt(overlayStyle.height, 10); var newOverlayWidth = evt.width; // |evt.height| is just the new overlay guest height, and does not // include the overlay header height, so it needs to be added. var newOverlayHeight = evt.height + overlayHeader.offsetHeight; // Make room for the vertical scrollbar, if there is one. if (newOverlayHeight > maxHeight) { newOverlayWidth += getScrollbarWidth(); } // Enforce |minWidth| and |maxHeight|. newOverlayWidth = Math.max(newOverlayWidth, minWidth); newOverlayHeight = Math.min(newOverlayHeight, maxHeight); // animationTime is the amount of time in ms that will be used to resize // the overlay. It is calculated by multiplying the pythagorean distance // between old and the new size (in px) with a constant speed of // 0.25 ms/px. var loading = document.documentElement.classList.contains('loading'); var animationTime = loading ? 0 : 0.25 * Math.sqrt(Math.pow(newOverlayWidth - oldOverlayWidth, 2) + Math.pow(newOverlayHeight - oldOverlayHeight, 2)); if (animation) animation.cancel(); // The header height must be added to the (old and new) preferred // heights to get the full overlay heights. animation = overlay.animate([ {width: oldOverlayWidth + 'px', height: oldOverlayHeight + 'px'}, {width: newOverlayWidth + 'px', height: newOverlayHeight + 'px'} ], { duration: animationTime, delay: 0 }); animation.onfinish = function(e) { animation = null; // The element is ready to place back in the // overlay. Make sure that it's sized to take up the full width/height // of the overlay. overlayGuest.style.position = ''; overlayGuest.style.left = ''; overlayGuest.style.width = newOverlayWidth + 'px'; // |newOverlayHeight| includes the header height, so it needs to be // subtracted to get the new guest height. overlayGuest.style.height = (newOverlayHeight - overlayHeader.offsetHeight) + 'px'; if (shownCallback) { shownCallback(); shownCallback = null; } }; }.bind(this); // Move the off screen until the overlay is ready. overlayGuest.style.position = 'fixed'; overlayGuest.style.left = window.outerWidth + 'px'; // Begin rendering at the default dimensions. This is also necessary to // cancel any width/height set on a previous render. // TODO(kalman): This causes a visual jag where the overlay guest shows // up briefly. It would be better to render this off-screen first, then // swap it in place. See crbug.com/408274. // This may also solve crbug.com/431001 (width is 0 on initial render). overlayGuest.style.width = ''; overlayGuest.style.height = ''; overlayGuest.appendChild(extensionoptions); }, /** * Dispatches a 'cancelOverlay' event on the $('overlay') element. */ close: function() { cr.dispatchSimpleEvent($('overlay'), 'cancelOverlay'); }, /** * Returns extension id that this options page set. * @return {string} */ getExtensionId: function() { return this.extensionId_; }, /** * Toggles the visibility of the ExtensionOptionsOverlay. * @param {boolean} isVisible Whether the overlay should be visible. * @private */ setVisible_: function(isVisible) { this.showOverlay_(isVisible ? /** @type {HTMLDivElement} */($('extension-options-overlay')) : null); } }; // Export return { ExtensionOptionsOverlay: ExtensionOptionsOverlay }; }); // // Used for observing function of the backend datasource for this page by // tests. var webuiResponded = false; cr.define('extensions', function() { var ExtensionList = extensions.ExtensionList; /** * ExtensionSettings class * @class * @constructor * @implements {extensions.ExtensionListDelegate} */ function ExtensionSettings() {} cr.addSingletonGetter(ExtensionSettings); ExtensionSettings.prototype = { /** * The drag-drop wrapper for installing external Extensions, if available. * null if external Extension installation is not available. * @type {cr.ui.DragWrapper} * @private */ dragWrapper_: null, /** * True if drag-drop is both available and currently enabled - it can be * temporarily disabled while overlays are showing. * @type {boolean} * @private */ dragEnabled_: false, /** * True if the page has finished the initial load. * @private {boolean} */ hasLoaded_: false, /** * Perform initial setup. */ initialize: function() { this.setLoading_(true); cr.ui.FocusOutlineManager.forDocument(document); measureCheckboxStrings(); var extensionList = new ExtensionList(this); extensionList.id = 'extension-settings-list'; var wrapper = $('extension-list-wrapper'); wrapper.insertBefore(extensionList, wrapper.firstChild); // Get the initial profile state, and register to be notified of any // future changes. chrome.developerPrivate.getProfileConfiguration( this.update_.bind(this)); chrome.developerPrivate.onProfileStateChanged.addListener( this.update_.bind(this)); var extensionLoader = extensions.ExtensionLoader.getInstance(); $('toggle-dev-on').addEventListener('change', function(e) { this.updateDevControlsVisibility_(true); chrome.developerPrivate.updateProfileConfiguration( {inDeveloperMode: e.target.checked}); var suffix = $('toggle-dev-on').checked ? 'Enabled' : 'Disabled'; chrome.send('metricsHandler:recordAction', ['Options_ToggleDeveloperMode_' + suffix]); }.bind(this)); window.addEventListener('resize', function() { this.updateDevControlsVisibility_(false); }.bind(this)); // Set up the three dev mode buttons (load unpacked, pack and update). $('load-unpacked').addEventListener('click', function(e) { chrome.send('metricsHandler:recordAction', ['Options_LoadUnpackedExtension']); extensionLoader.loadUnpacked(); }); $('pack-extension').addEventListener('click', this.handlePackExtension_.bind(this)); $('update-extensions-now').addEventListener('click', this.handleUpdateExtensionNow_.bind(this)); var dragTarget = document.documentElement; /** @private {extensions.DragAndDropHandler} */ this.dragWrapperHandler_ = new extensions.DragAndDropHandler(true, false, dragTarget); dragTarget.addEventListener('extension-drag-started', function() { ExtensionSettings.showOverlay($('drop-target-overlay')); }); dragTarget.addEventListener('extension-drag-ended', function() { var overlay = ExtensionSettings.getCurrentOverlay(); if (overlay && overlay.id === 'drop-target-overlay') ExtensionSettings.showOverlay(null); }); this.dragWrapper_ = new cr.ui.DragWrapper(dragTarget, this.dragWrapperHandler_); extensions.PackExtensionOverlay.getInstance().initializePage(); // Hook up the configure commands link to the overlay. var link = document.querySelector('.extension-commands-config'); link.addEventListener('click', this.handleExtensionCommandsConfig_.bind(this)); // Initialize the Commands overlay. extensions.ExtensionCommandsOverlay.getInstance().initializePage(); extensions.ExtensionErrorOverlay.getInstance().initializePage( extensions.ExtensionSettings.showOverlay); extensions.ExtensionOptionsOverlay.getInstance().initializePage( extensions.ExtensionSettings.showOverlay); // Add user action logging for bottom links. var moreExtensionLink = document.getElementsByClassName('more-extensions-link'); for (var i = 0; i < moreExtensionLink.length; i++) { moreExtensionLink[i].addEventListener('click', function(e) { chrome.send('metricsHandler:recordAction', ['Options_GetMoreExtensions']); }); } // Initialize the kiosk overlay. if (cr.isChromeOS) { var kioskOverlay = extensions.KioskAppsOverlay.getInstance(); kioskOverlay.initialize(); $('add-kiosk-app').addEventListener('click', function() { ExtensionSettings.showOverlay($('kiosk-apps-page')); kioskOverlay.didShowPage(); }); extensions.KioskDisableBailoutConfirm.getInstance().initialize(); } cr.ui.overlay.setupOverlay($('drop-target-overlay')); cr.ui.overlay.globalInitialization(); extensions.ExtensionFocusManager.getInstance().initialize(); var path = document.location.pathname; if (path.length > 1) { // Skip starting slash and remove trailing slash (if any). var overlayName = path.slice(1).replace(/\/$/, ''); if (overlayName == 'configureCommands') this.showExtensionCommandsConfigUi_(); } }, /** * [Re]-Populates the page with data representing the current state of * installed extensions. * @param {chrome.developerPrivate.ProfileInfo} profileInfo * @private */ update_: function(profileInfo) { // We only set the page to be loading if we haven't already finished an // initial load, because otherwise the updates are all incremental and // don't need to display the interstitial spinner. if (!this.hasLoaded_) this.setLoading_(true); webuiResponded = true; /** @const */ var supervised = profileInfo.isSupervised; var developerModeControlledByPolicy = profileInfo.isDeveloperModeControlledByPolicy; var pageDiv = $('extension-settings'); pageDiv.classList.toggle('profile-is-supervised', supervised); pageDiv.classList.toggle('showing-banner', supervised); var devControlsCheckbox = $('toggle-dev-on'); devControlsCheckbox.checked = profileInfo.inDeveloperMode; devControlsCheckbox.disabled = supervised || developerModeControlledByPolicy; // This is necessary e.g. if developer mode is now disabled by policy // but extension developer tools were visible. this.updateDevControlsVisibility_(false); this.updateDevToggleControlledIndicator_(developerModeControlledByPolicy); $('load-unpacked').disabled = !profileInfo.canLoadUnpacked; var extensionList = $('extension-settings-list'); extensionList.updateExtensionsData( profileInfo.isIncognitoAvailable, profileInfo.appInfoDialogEnabled).then(function() { if (!this.hasLoaded_) { this.hasLoaded_ = true; this.setLoading_(false); } this.onExtensionCountChanged(); }.bind(this)); }, /** * Shows or hides the 'controlled by policy' indicator on the dev-toggle * checkbox. * @param {boolean} devModeControlledByPolicy true if the indicator * should be showing. * @private */ updateDevToggleControlledIndicator_: function(devModeControlledByPolicy) { var controlledIndicator = document.querySelector( '#dev-toggle .controlled-setting-indicator'); if (!(controlledIndicator instanceof cr.ui.ControlledIndicator)) cr.ui.ControlledIndicator.decorate(controlledIndicator); // We control the visibility of the ControlledIndicator by setting or // removing the 'controlled-by' attribute (see controlled_indicator.css). var isVisible = controlledIndicator.getAttribute('controlled-by'); if (devModeControlledByPolicy && !isVisible) { var controlledBy = 'policy'; controlledIndicator.setAttribute( 'controlled-by', controlledBy); controlledIndicator.setAttribute( 'text' + controlledBy, loadTimeData.getString('extensionControlledSettingPolicy')); } else if (!devModeControlledByPolicy && isVisible) { // This hides the element - see above. controlledIndicator.removeAttribute('controlled-by'); } }, /** * Shows the loading spinner and hides elements that shouldn't be visible * while loading. * @param {boolean} isLoading * @private */ setLoading_: function(isLoading) { document.documentElement.classList.toggle('loading', isLoading); $('loading-spinner').hidden = !isLoading; $('dev-controls').hidden = isLoading; this.updateDevControlsVisibility_(false); // The extension list is already hidden/shown elsewhere and shouldn't be // updated here because it can be hidden if there are no extensions. }, /** * Handles the Pack Extension button. * @param {Event} e Change event. * @private */ handlePackExtension_: function(e) { ExtensionSettings.showOverlay($('pack-extension-overlay')); chrome.send('metricsHandler:recordAction', ['Options_PackExtension']); }, /** * Shows the Extension Commands configuration UI. * @private */ showExtensionCommandsConfigUi_: function() { ExtensionSettings.showOverlay($('extension-commands-overlay')); chrome.send('metricsHandler:recordAction', ['Options_ExtensionCommands']); }, /** * Handles the Configure (Extension) Commands link. * @param {Event} e Change event. * @private */ handleExtensionCommandsConfig_: function(e) { this.showExtensionCommandsConfigUi_(); }, /** * Handles the Update Extension Now button. * @param {Event} e Change event. * @private */ handleUpdateExtensionNow_: function(e) { chrome.developerPrivate.autoUpdate(); chrome.send('metricsHandler:recordAction', ['Options_UpdateExtensions']); }, /** * Updates the visibility of the developer controls based on whether the * [x] Developer mode checkbox is checked. * @param {boolean} animated Whether to animate any updates. * @private */ updateDevControlsVisibility_: function(animated) { var showDevControls = $('toggle-dev-on').checked; $('extension-settings').classList.toggle('dev-mode', showDevControls); var devControls = $('dev-controls'); devControls.classList.toggle('animated', animated); var buttons = devControls.querySelector('.button-container'); Array.prototype.forEach.call(buttons.querySelectorAll('a, button'), function(control) { control.tabIndex = showDevControls ? 0 : -1; }); buttons.setAttribute('aria-hidden', !showDevControls); window.requestAnimationFrame(function() { devControls.style.height = !showDevControls ? '' : buttons.offsetHeight + 'px'; document.dispatchEvent(new Event('devControlsVisibilityUpdated')); }.bind(this)); }, /** @override */ onExtensionCountChanged: function() { /** @const */ var hasExtensions = $('extension-settings-list').getNumExtensions() != 0; $('no-extensions').hidden = hasExtensions; $('extension-list-wrapper').hidden = !hasExtensions; }, }; /** * Returns the current overlay or null if one does not exist. * @return {Element} The overlay element. */ ExtensionSettings.getCurrentOverlay = function() { return document.querySelector('#overlay .page.showing'); }; /** * Sets the given overlay to show. If the overlay is already showing, this is * a no-op; otherwise, hides any currently-showing overlay. * @param {HTMLElement} node The overlay page to show. If null, all overlays * are hidden. */ ExtensionSettings.showOverlay = function(node) { var pageDiv = $('extension-settings'); pageDiv.style.width = node ? window.getComputedStyle(pageDiv).width : ''; document.body.classList.toggle('no-scroll', !!node); var currentlyShowingOverlay = ExtensionSettings.getCurrentOverlay(); if (currentlyShowingOverlay) { if (currentlyShowingOverlay == node) // Already displayed. return; currentlyShowingOverlay.classList.remove('showing'); } if (node) { var lastFocused; var focusOutlineManager = cr.ui.FocusOutlineManager.forDocument(document); if (focusOutlineManager.visible) lastFocused = document.activeElement; $('overlay').addEventListener('cancelOverlay', function f() { if (lastFocused && focusOutlineManager.visible) lastFocused.focus(); $('overlay').removeEventListener('cancelOverlay', f); window.history.replaceState({}, '', '/'); }); node.classList.add('showing'); } var pages = document.querySelectorAll('.page'); for (var i = 0; i < pages.length; i++) { var hidden = (node != pages[i]) ? 'true' : 'false'; pages[i].setAttribute('aria-hidden', hidden); } $('overlay').hidden = !node; if (node) ExtensionSettings.focusOverlay(); // If drag-drop for external Extension installation is available, enable // drag-drop when there is any overlay showing other than the usual overlay // shown when drag-drop is started. var settings = ExtensionSettings.getInstance(); if (settings.dragWrapper_) { assert(settings.dragWrapperHandler_).dragEnabled = !node || node == $('drop-target-overlay'); } }; ExtensionSettings.focusOverlay = function() { var currentlyShowingOverlay = ExtensionSettings.getCurrentOverlay(); assert(currentlyShowingOverlay); if (cr.ui.FocusOutlineManager.forDocument(document).visible) cr.ui.setInitialFocus(currentlyShowingOverlay); if (!currentlyShowingOverlay.contains(document.activeElement)) { // Make sure focus isn't stuck behind the overlay. document.activeElement.blur(); } }; /** * Utility function to find the width of various UI strings and synchronize * the width of relevant spans. This is crucial for making sure the * Enable/Enabled checkboxes align, as well as the Developer Mode checkbox. */ function measureCheckboxStrings() { var trashWidth = 30; var measuringDiv = $('font-measuring-div'); measuringDiv.textContent = loadTimeData.getString('extensionSettingsEnabled'); measuringDiv.className = 'enabled-text'; var pxWidth = measuringDiv.clientWidth + trashWidth; measuringDiv.textContent = loadTimeData.getString('extensionSettingsEnable'); measuringDiv.className = 'enable-text'; pxWidth = Math.max(measuringDiv.clientWidth + trashWidth, pxWidth); measuringDiv.textContent = loadTimeData.getString('extensionSettingsDeveloperMode'); measuringDiv.className = ''; pxWidth = Math.max(measuringDiv.clientWidth, pxWidth); var style = document.createElement('style'); style.type = 'text/css'; style.textContent = '.enable-checkbox-text {' + ' min-width: ' + (pxWidth - trashWidth) + 'px;' + '}' + '#dev-toggle span {' + ' min-width: ' + pxWidth + 'px;' + '}'; document.querySelector('head').appendChild(style); } // Export return { ExtensionSettings: ExtensionSettings }; }); window.addEventListener('load', function(e) { extensions.ExtensionSettings.getInstance().initialize(); }); VYoF~ׯ-&0TqICQŠ;4W^.ݡwiӔwvo.&p*ƪۜ3V- U𾦼.ZCC{282ʕW6EHKA r!thR4,F H β6a"oW), ՇW| I@ AMY.`2)Ҹ+U9vpi%q0K2w^.Gr"H=xsSb¯Z7=Z9zXZ Q ."p6=hkle$X_$K!פ \KAkRzW*6K6l,=(+ `lSi!9SR `j>$| 6~~ߓp>J.!NL{^YCrk&+ ?|02F9U̼wE3six*'͟Q+--?)c^s#AQ>!9@ALke(0%)_$yPA|_'VK&-++/Sֲ1k͗\g-|)1oV?WURB&2fkyt<6qzc[)Gw <-g8_|Ma%T|8x2/&B̭(u0z!if&oeRY0[ɣk' ל -xrVTўu ?sm#b9Q|X=#ˣzm"ӓbƣrui"גv&eH 閻 \eQב|n#_( _qU^ Tn0+$Hr2P4M{(PT@#0E$eG-%);c%A͛w~7ji`$ CaZ5mີLiõ@4kAP5X  R.D eݾJfP %zZΎ͇Ϸc8;LD,Fm# 0˓|rCcR#YF\NV tzQ\]L!Ȧ͒SY+i#84I!z](ej H1jGtj\ɀܬ %FPr@/֪1lz3N)>JbPEK0mL_(?#+k"ZOX]HˢqAO$r{^_#O%v& `tOsr^#,yoཻ}y#𣺮{C>Jr˗U }Яo  14ފt>ZpVV?uD_N`l!?[%7t8aGWipbQ{Z'cɂt ,՝3k~$Dom^j6Q70JXZkzj96ϲ}4)ѮlD|>"8l,OҀ \FV~\GWn7}WL"Z N‰abL-$Wb{HŲ\Ϝ t˕w3GoxK3әsY]fؔ)EAȒafyeSr3idLΙN/dEޏ֭xfQ& 4U,ptRqd&y* NwS[pBZοUp, e}VE.=t \ƤBɟ\nj2˄\jP5@&*庽wB脺0EQ 5XΤc% ҭ}FIyN" dQVBo{IZySَE{K8kmzz!*Is^inU2}Y+\ZQ,L}36yx{(9J):vUt{wCbw +TwCԏ[hCt&;ΓPVB%>)Ds=jl=4JFÏvj4 UU{B<颭2w~uqhG,͛ 6^k`$26So{JX '9=DQϟ0bNcx;pWv_6h 6.ə*~®vrgǵ~d)OmQ%xyvKPI<ɔ Q깜ŗl@x1<SaivzYV+ 9+Z'L.SVN W+ ?5j_v"<ַR~bNz52땬]?K#AѼ*֨|嗟+?ėa'}^(oct/\eOȱo(O{qWsN76xҗؐ t3r&¢h7wс~۫hdKns:ܒC[;=q`akE8 ؕ1pE֗~Jl]* }k۶w dce2mLd69}> x#Rv;'?c_UxxRT̙[ BP(³|חoyvʪy~OU~;JCU~?6Z~H^xϛ ݳJa}VW?M7@z,Zr ¯k'¢jys8 ˿~ooȋj.`ou} [o72 G97KP{˲¾7? _70~Ooag?GGlKWh:l >ߟ=eivBo~lׄμuCVϊuSo RQ4img͡ږSHdUQ/~i;+o F7V/Cz]ߒfx$^r筪 z_=}袩P$SyǞPooVUDlUQ֤vPxB{uȠb߿fA7dwUz-e^z q>Ez=^s) 6u^VkK&lFR%Y/77ğ90}Sd{Pe(@x0\>ѳ~GԘ9uyGKt-n払 K&(PaHu/uUdGziϞR3ų@|>%!`z=jm&~Q*W-d̃aW:/g/F. )}`#JXR̋qIɎi6͋-;2I%{N4^DGj>GV{1+UqigϗyYIĖټ\789 HHe[ jQ, u"TBAeti`d2))evF*Xaz'HҒϥ![>#aAǖ/J$+cEgr*WEYFfnh\-En>7e8 .-D1' Ofcqj沖qVfnƇAE"aaYY#F[y³yX9t*T 既$Drڲ ,ٜ$[-CK6`-"K6" 2,d*p9UWfҒz,/$[YeY@% a:- 2cDAB,2w\L3ICpn`4du2e"_&eqi"/+ČDI[| Wyd`9ŽueIf'w&r@p$jiHqZ!XK Vz~%%4%%1˰H ^Ƌ\X7/yϠ&k4o]O{c˹zJTW2cXFeX'i478)K2βju^$ c, W`|R$ڕue"#._N a:_,@2H` A`}Jw\\:˻?"UdɎD{]^ -ټg쾴QX%wp yy>dN 8[Yyw`f[t$tVwaE mq h5n+WDE"dn[’--*3~elmbn#M4|ٲE4mIlmWO9Uikﶰ$l]T6IC-U8.PYagi9ӥZ$b%|n͖зVhÌo<|{I~n0|nyG|n,SYb5+ ]X9tCo6YD'YgO+W*ӻR SKؔyb2B7TתE1::[qf| lȺ>MerC-F)LXn(.EZ-0p3(#.j`0sS0b+cZ Sj"dEaɎ %YweY K6,F_IdgbK6߃)&@b7-`Xi 9`[ݖ5Zzǩ m>Yi@k ? IK;b/:^ZX'27~mwXFv"YGy1,v|m(Bg+roɪ]o[h[,[I@$"[2UYhB<s[>2Y}ݕ=wj@_[ybб qTѵaivbkbL4<2XFVAeQdv #fhvi,^eV8bT2~4s,ܑ?CeX8k]:K.{J.ɂvW- }W^^n;ҍ"J4pNHyx'i22Ԝw\ 7TƏ8ϭY0N$ _g$,`ߕQa Ʋߋ{4s0k3[><[>,gʙqH'ciXg=+B7RX>?ui<6sD(,̵K,ٶv)nYϥ20^k,Q%{wǺ`aڶk%2+  `I<ї= _d`ᬋP2[`T2#t3Niot0<'+ g,p ػT~7eu[8Q:]x4CrCJP)XZR;t'gT[ "E IBӥJ\cNtO?UE}+iZtu;)Ёpw>)_(]%ʝ]v`˗w=ltlUjWnxoNՙ5TPT{<8l‹g G/T$ S0tX*k`p4&b^#g1s.tYhME)5fȏzf=YZ!==+z!?%o~d>w.+DUIjM#~דa|!f} F?4בt>j ڋvEIH9}J ;K:F~+ɎK37]X>x̓L,sP;6Z`pAҁuP/yrhbbՆ|0e&7^fo.覼=OTRcj t/& F\ r('g r'*|9T{E4Î*R?߀>`Y ![Z~C\sso o(P~W2n(еq- r*Q,' i0Sz 0j^c KǙZucrujCV!̳a?džPrS'Z-1cl7p[=P=P3*mѰme>REz}@٪%ةۺA dws-H>?t+-2?WXׄ_Fs[{bb]9߯a]vj.QR{ ? #becJ[6A'.s-nBX0܀w65A+֙(ni#p0Jud r:9$XeM)7/0ҝ-x؀&y~&=`yZ>hgj׀j-S]ϞP?7OfM\64'H He^M"d t1&qQi 4ҰRTKy~ t1,WsPG O'yPȇ}lH~y bm|:u偩CM܋40vW0 O7D )+5dխ?.AsOӕ7BJ6Ě-hlo{ :Bߏ:F5sU[- ݟk=D_VB hךuD*Xe[f*uۆ&[%6rStiڦa^˗mw1F9{>_ҏȚ# Hmb/k*N~P'Mc%&] ?o-%; @2iK^i7R[ c>E+r,Ӆ@o;K.o׶uLg|Lޏ mϹ5sBlv v{i6\ ޤ\UH9fewm;֞b90P?%5Gvqԏ3L^{_Q݊/mhNet jus,O>t U,KH甂}JOV2,ɣwuUe3iG D:)rbb>2Z%ͩTZ7gN2*Ċ eLlT"dtlZ$s/?RcJ@/)y\liPdHK}es_P=-٬)V(tq:7ΙGRAh0*;5wҤ 5˷B' L / 9u;aqT9˒l۷4 ؅ޝqs:BJ`A5%Wp%s)#^f::@hM] rd>ZA UvlΆӐ$ *}P$[!ŔrC]-vWW@0 Λϯ@ٹsԞ T u.iĀoԔ{"لh_^l?ӕ,2'14\duL=@ճ4 T9PkNbc"!sdFh|uyldeߐ ; yٚ~!PD4TBE~yjp\g K -b 4Ǚp^~- G @nV㜗2<;Xx4߿Uʍv/?D?䅏ttČ-k5 K뺩>"7J~(Io jЃ}* oZr*%EA8?bQTKqGζZPXj$x'~ f / *VX=,2mpKk&UxK3?ZuF="!;DCvY1C{"!2;ĢX!@bo %#168Aҝ##fT\thXّ\1%DP|]>ޢ#S93KS"j`StI ٹMYYad=2I"~gYi@zҚK9W֓^' wZTu#m-}.`%Rȱi ejg[QF}iRpnTJ֢^T9ڹ:ãJ% `fua YibrXԍC}rl)՟99 JOĩI}L!zmRz;(*ّ>Wha}i握E!~Ak@8a8U<{ !_I?tBZYVhiD{E5p!9ݎʽ^RX]jڋD"d lcOtl#%Nt":":6D4dtJFlA^dg63A p* IO񩤇!9F<;2;xf''o+䡋xJFM}~ ns0L|¶"/ 1;.Emu;0wNH 8g#q&'FLO M`:Gᤈ9&V>]~m+al> 56F]:|KeV$['"F 9GvQPGQy zyK 8xcx,{p 1nLd!`!wo͝/B3zE/R һÚVtū5 4yՒTu-zSZdNTDI?w2$q=k &_qVoM[7de1lۧM>:]VF' TKdmzL@I&Q^ֻ-~6kjz\Tȇ=zK@)5;(4`jbV+rPj~~K' gEMS~h*"j! W)}He /J1QL5ljx.x!{ɧeY@  lIG,O(v@ISjvc% <:"ʼoJv ee*=YsG4.onsy޾Bi~Ʉm6vT?^(Zƫ zS6jtbd* ]w%݅x*BJ-5=̅y{ klZ6DUZV"-9at_C>>)CCxNp]a9'RUnjzp}6Guq(bO&V|JS k8JXe&ۢO&IFlS횘!FHDNلiȤ*OVm傮LnYxSOں=m![pF⃸˨'8M Ĵ_0#*Knوlğj=G`{ٲ J[gC{46rIk==0 @E0\1ڈ 6v3w=0,w쏆z݅I`_cklћ-_^q]Lct@,ZvjQ®}CVՇw#6!!#ɵēNt7`@엮;eČG51 e>yȯwڵfUAWluMТ3wxOkՙWB_v+4odhy;dՏwHlN3x9bV ҝ]c7PpYyh{J?!>гkG󹱓} ݳ6ջ?{%L-LVl&怰V {L=f|L֚İ$L(C N O@q0I3SJ"d8jڌ>@9Ja[|C>{J?8-pǕ HhyL|Ӿ(I[4RV$2@'=5"$|a~:xo#*NݜOGt<N`&$0:ʒ~f);Ym{LxhھА^EJ$LaL@eK1F`ԗbſaa7dw}X0?Y/%9w {P>#1ΘnsǙ`>D V|ؔ0Ĩ.eg+wa럟]ջc#EE`k۾2šl-oLዂy#e!V{.طb?ͯ' !P=8=5J;muFt".ƟE ]|4oQO[|1rbos *V#LxIz?ʛ0 | `=)8>e*Lsow?i! %Q#-hH=ɿB =YoKC$!GvKdiP+tSFj>tdYb*2O{ў^ =~d^1q6bSt]L>{/ L#u&k\ܴxaHXeu|~$XwV^<{jOgŨ5`,52hrGEg~WVM|^؎w;whnU5[g=8BM#0)0=r(`-b+ڮk-tt(ά$ޫ<{pE,Mx:_|`_3G7-{KtRU&~SfoIoaAKaHK V|/LNo `h!&ݟ?!#!sJ)Mͱf a ve=(@gOЮ;ݙt/`,ϼۊ,2_KxMg 3k}ɟ?/}[IqZg/TN$>%)}^qgg^OUi ŸMÙS4|ŷ iR@cp%8if1Km3MP*460\Ogi@y7b ^tϧ3I9dz8,h~f9"̡S!m2M-Au#ёؚ_"^b1w<ʣ[|2b"W?$IZKRLX6K ,^e)K08v[}zG/:m4h7{ M+t$f*( E(TŚKmi0^eLF9]F?0 yH߼x69"O=?b*R>4y$ ?>h%) ? / '+>&/gO0,ҍd_/1aˠ(00G 0 3~.~>tlо*p[}9^)D[PL% RTi}?x}}uC]mPsk[h 4Ju9 qUA?C/tA4u.Fj"}.~!ȁO~ Q4X*e/ISAum<;dR6D_o>O>MQ@rS%`3VD\[?Y8eRx^Gf0 7uk[P/?ƌ `٤W*™>3Aj 7M[4@ ]֏?nSA :eocA&.^ %.jTD5z`bT<_'c4>`B;-CLWʢ!ɂB/.X'SED`@Ob>)B4n{(E,ib.OΆ;5&&m'hG0 f(r[ۡ0 qEf#uCi=P6LȺ:a9҉ \lo: _0C$U-Ѩ`d ?#;LG!j9NgLZBeAa6C&fT{ۘuH& y? ijE yg 0=_![B|T/!H\BrS j{ /i%,_.CxMQs|uAXw ɋ+9pZEv 47`DŒq)s‰/ɠ k[B^BWi#Yj`:' gЧ t+$-t'%'.>5[ Do+ F΢uSO~K}KL'{c[Fq9t^lʯK萢EY(M/DiyίCt|X!jwK"Ӕ0Q\6xl1C?Cc9<I,x_{ߓsW@v!W)L+y,Es`wHRjXuaRkz΢Kg:Eޱa`-%α'S47u9$qEŷW1/LCQ-V'0x zP#b>SwX\VjJRMΘc vԗ "S0%`%Sk&Ər|ϒcquD鏔fev;O6&YQBR8 |,{tpE{^_NM?+d~(,l/M9Lm{tnWB$w,V; )Bow5sj}`歫.,W.X4\nO졾@|A)b.+z_i׫M_=_8 Qou-HyF*YoB_ۗ4 mǤ}z-n9erCr!%JP î6~-*u\wd(cԆN:]7.~X=$R.ix UM4s ݪVF%hqUZSNy d30|wh"hn zN`Ú͐}M~}"tl\2)ÿij.㶚^ :xkÄܵ‹a#N6RmJ+s[RVa.<{nmU_} za:ǝ{IUH.mQNx#&s`d*XR<\;zd}' 4`~ǍǛK7~~bD=:U\Z^*ݙ?c_ied5ҵkvKnѬOY n܂ٽ]m*fyԲ\>vVo.h;~ʸk2[C ]n,jf1KQ*zQ*DamhM,aA^".!忦8QXUl]/|v}+ofؠlXTSd򁷊VV) Ǝ'Kz umkX i{s4?:7T-~T`p2Xj-&KOts|L7#cnl9v|Zc{7$;S:s-x H*J-[m;Do҂-e$%#`~7:BZ1 ~VS]aYQVJFU&{~l?C#!Ͷ{UP>Pj.r4whh 8IuK58*Ed4(&%0<^9?T%ѝʮc׌EsrߊQ%,n3̗ινq?c<{~&ۑRLvq.џt,?R<䅦 !:֠ sO{}QLi8K6ỏ/A8L-T@}w}݉|RHejUZ;h}ݔT./VExp@JSZ3AfYپ/OyV/s/mxGGpt6,}chzH _CgubrƮwWuyqK)x#T]>f&''C !osYV ?1J‹+3V|`m (\kRÑcƊqc]E+i4zdkN{*hʪمn7Ex,mu?"N,ύpbB]Y_= lvG#wgOEKNƶ}8K勮v9op$ƿo2ݑ7- ɣ. 'h|Fh:/ 50gyZLhVi?P<=v:;72ϘS@JxJY+[c{h~ΚO^Ƈ-flwHgb#kƀo P0%Y~l}s`h9I*"}jX8Oy=Eatp*lZFRե.-hC29)@l*$xWw^q+^ƛ 'py̡^*gVVEڬW:;)sb;" ^c6QˆP7 ,+t#b? +[5fE=:Ԋf23ďy cHMh/%#TGTm[Ģ{_zfx!8: qdC,ܺIxc;P'6@!cc"9}bU8Ԃ8[8j1: ;ϏNwNlݳy{~8 ;Icr:%bdbHbHaqnWT2'ԙPԅ/^MT?2.FGu-ކJW0.Ǥx 5NچxJO1ά`kqrOgg:}O_#V;-h\+오_XV^_X_ 7sOc\1cŧO,}ѷCov G*>Ƣ?3=mɴƱ S"Ztנt5Z?qO!9ޞ?^JE.]^ Rn1WEؤA,FOhuxlCk`?a-;FQ5T9YI,-}:NQ: Wٷ\=jbGBOs^űa4ŽSmoa?P_8ǝI}p۷W% ӧBcW`dqR3CQoËnGtFO >ɼ_UE"㉮C~"92X|\wdmj|#j3(eX5-ޙAt둝H_:|3(b w9\qb+Y,=VsF;?ۃdIλW^6`^ʈ߿Pb;3ж6-STﴙB~[r_X;+Ú X@[Rȩ.u\c_XVn§1駽;iܒuq|}5$#{WJC؃rW`Tt9C3be}p|B9f8廋 dγ720lYv{G?}`3 hv7]ٜJ'S2hs]W/v۵t.į 3!ǣ}+TWRDb`z7C+;De۶iNJK<x7'esY(ilnd]p]\}?zV~ |e^Da_44G.!h6n׳ihwE 4c#@@b}bk۳C)6ܐryFՕQG(޿;j9 }K}YXqFe>2}C)o,cio $9%AUud-Á9 ėN4+|^GS_ .4 To:^ZRK OBKgjd\âuix̍ u-J|8Xg<(q! \>ݻb:})]v0~{ {j3A>T/lx;>tE*?Xty0Бj Pڌh1 E˺:gw5ZwV/~ iTh6ձ㈏yp< gnՋ,SǨ@{ &2͡vD׭}GjnX0X|8q= @_SS ~["9 ؾB7ߦTFlv1ԀLF5Lj{'a$w1`$4ԯʔ[m8t1$ f^'Jw֯f#͡qXPu5@^[O5'vDEm~aՇ6`P##xD+p)?8H\ˡkccdnb/sex5RZ,ɩX!ma!mq_-  -j&DE^E9U<6#>+83Ёg" Llu-n,k/ B]pA}n)iH%Bx3 ZP Ku}#[1==#(lذ{M\!I N(#đ?7]Y华epB8HO)1w=xt%~Ԡ1e=pMNv]uQ;u'UĈsn i] I_%k d1%O9t[6U=+sE!Ѣ ZOd'm3 `zZV DE"X\#Oכzo^jC%oð18:&A6$Ysrx_9}D憠E)7􉐙p`AӺ$WuuM kf^Mq{ug땮Izy V^(hBIXcچƽOa ?Ćы5H u3Pf;vSuP ?ʫ}^8\caHzExBѡI@AAGL )Vsv|Uxbr#yK!;Q!iLd1c˺+`#}Ȁ*gA҄1Uug3oWON,S>2ӂ΃@WFb?^}~VԋfG<t/vً#qٟcD[ /?ţW$[#C*E<`RH4`AGnNzjXB!ۧuO;d '~>c[#`wg}n^?oBܽkW#G(} èLI mL>4c~%jҨ?|Vm>ܨ~97 xm;+bߖ7mPjcWc>۳g|ޘFU.f|cP )?I>ܸo<;hWQ1*oAq#r1nH7^n\o׋`^~M[r2,0!)8M/֦Ί ˢ=0su][Qm[.Y66WKƯQye VlBYblt (Uy^͋M W%?N5>9c+B4nǥ?rvѻ kF2<}Gf2h'i^΄T7\HHբm,R[3鏡(`= '0o*?a8Ll3^W3c5z4zKO;pZ&?]uɰ9@R-lt^"M>I1[%Ufx Ae0l@ NUƪi!2vM">Kd4V<9yㄫ!Ѫk|>Hs]hZs@34f>&vԋҕ|aU\u OݷؕZ8ԴmjlEE9bL3oH1K,?Ѭ=i%JOTYR̨Π?&|tgiucYWaO_Ch3Cǰ4 |X#'w9?Ha-ƏhrI`L__ေP0"&7!!SQb?%ʻԂk}3?93g'gz*~bdjMIXoÇm9O9\qLȒh J,ʒ uHh.w(fW"#~`wsԫ wW*5m~t;Xb(soD蚵qpP׵8 FkDJYQK~V+ݕ,ĀVPPW3bR*M"3  xFL>˰*M1LF h$nzٵe b"CQ1.@.?C=Q2πd\sJN*a,ݙ~23aAxӍb_?ch1! Jo'A?^AfsF;tY&29QNBJ֖&}Ye SC|y*߂)ۮ }*g|(&;vC)H 0}BSDo]9n-e0 !0>ZS kqaVC&Ĵ82ӟ]w"ш`5j Ҷ#X̫w WWLUwt֓Zͫ(!t,y_Uo?'{ VzUL 5) q}υb|F-yRogkEc~rdywPbBEUzYZ bI-Z ,|/!{&(ruD'3ԓS6@%W ]ؼ5m!cՈ@GTIP`ĥW*\[@!.ڀ*Q.(Kꇧpy{K}'2R'AZOZ^va*#|5*jpe&?]W{=0IU&x|/UVa1yd&5h^.;1 @j-hKv!)Lq$7,6G=SA!pT޴} *6 mhp>̩ͮa붜-8Ro ѥ%nS*ADqWxQÃ.vJmTjE9Lef72]#q_Ks/`_9 M\SCz'B!3b -M=9(SZ$i95'&嬸LK2X`y+4ѼԐmRJ|d%4|^Nkj Iqe|lWؑݚع 3 tӮ E^:J /{2񂰲o^ET)ۻ.gTȜ_*dʎvD@{!D a=[xTq+qkXVKf4~U!Bۊ*BcɃϣ$#,2ی/[VH+r6Axd7 m9q*"f4%g%Wh>(+R-U?u/]qD_*R |ǀF\FI1L4[Q)*k2?? L y1Lٸ?=2[ D"syMKW,XԔNOF([[^@.5 q;6Ic+ <VNR=҆ZUkGkȁ#XE^LlUX&E\j,4uzq k7m9薩H[]~>x& F]$8wڂ,;=996) hb6FW̢Y^J3?JJni(Bjsi}Gq?)᯿@ic5 ;l?;-f+ʋVa#z4Ǖ_IeٲjJ8s:g.n]Mqf{="|.Oe?gL$:,e%fZ1čn}k;֨q7 -;Eh(AbzPw)wcBYCƞ؅8@/3 m%`yXvuUPži[|) t2F3]T-Rn!npMh"JxKT,j6V2|zT|T?/+<!e.jaE:|#_8gV쒽I#YmFSq+OcA⬐n zZ렼rc [}C;q3 V:9?<;㙫o`"Oi.~ɡ%U'fƼ 9mP)-"kPRw⊏8?u[KĖv"Mo_WJ|vV~e|lۖJlcŰ:f*/rQs\yPB>ްD멣a|lJ%G׎uJԽ|x؉WCh3JG/njOqIsu+Og%LFbj%W]db8} WTMfft'{yaϓO~J^8_}|xpteO{w}S)T=8|}x~~=<;{~m>::>攣$ #ӿ&goZ.a>Oߝ_%oΠݯ7{?~{>z&,اSuKO\Cߏ^>{2F쮁%)KE}7Uliws6SI-V[Z-9H5-試cmKݯ_{EQ\3Vs(akF'aL!V}42vu;WXIaldaLv@]s\q*5J3+[?M\`./h,3ډbq9,gFDRYWqe(}uV]?¾H?[6]GT͜0Ubc!3~l?x¥Yqʈ:uaX(\$ɊcCVA!Y8srFR6rdr淵w}LGr=9 V]h%qj 075bwU|-a gA\>},a$ <&ZZu*=a7u.cMZúSSS6m2UE zL)1]G)v):L}"u<2;%q\Pk*nqZ4LR . #&"8]6,cѩPJֺK9pYq}'@\f͡MUrB@\' ۵F(RP<˚ڨKΑ8=k [:6sS*3b+4Hה`v/͖ qС҅ Yap}w`O9_wI-c#&(U_Ԗ t WW4^J1RZMSCyhМr~q:{IDpNۛ +qFێ V~U'c0 כ#ElZu]OxfvXeH]ȡ5*H=F&upM5#k26%Emxқ]N ̰'*G}3}$4gl)~>t6` zY=W gvXGy++,.l%CqoSHQl:ډo'<Dtt`\fd y4']^< acj$֓+#k8;_FburHz_A 䜼䐚]1/RsH2֔5_Ȇ#nyХTЉBGh1E$ : |r>..w}LhXBWgaTYYH M@=@G(t%b8/O~CjӖk 2sI=͋ (?av"tx;6y\#k.;ԘnCo-+:'}(MuI41bGfJscu=NN&Jxt^XU;7iX9otɋ 6ijWMBMC09Mif]HJعct v)d*UÍON fjSAX\cxK[=,~ٟiZdr=Iks4f3ׯ%ˉ͵u(B$6ײ״EkîE K#"-;4dq=md;ac˸SXQÞ? 4? K=uL*&XN\Afm\B/d WIѮ$BT&56$A. !zpy W8A#8|P:- |Iu$|cXOraY۪ e;~ ̇g!eg^,y)nY2Ŀ\: J`rn}jp\B( ϫT]Z:_L  [Z4# yIU+f_>zv_0 |'4S!ZO&wѣ(7yk\8O,*PwԟCE9$.>,j(n&?ѐE;v=C<@"ΐq'Ks >=>bF-yz>A|zzx?OW>zx>~vDѺzBѫogF$͟zmHSɴJ!wZU\F\$\ʳGà\ՠ?uQUP-n,2EFj(b-f\jѽ"AǮ~Sxgopb,jD \d N0q9) ,UdgEߑQT:-~2ы(6t{&<2bAqcm Tg[뱸¸@X)"=NCXyM3)yk'bjh: 6 Ԛ~.XV6[ߑ])sS@.?Ӄr*o?a D0&]ìU*뽖곭LWևZݮGs|S]Va/q!NN-ϚbQ2f^P @~O VKG"k5J<ҤFjP=ޜuzfkGPAʇy8J_0BvMm:INn@`0js()Z~co㢢gaMe<OX(?zF9C4i]}3+vH~DYF2 6u-q{߽x^Zʰ4.OMrܔFx%='0yIi/Z%j VhfW7(4˃k"trִir/e@s/T!,&ʶ0n0/ҁ0N u szJn/eFb(^$eCSɳ 9x7wŦئ(P=Kݽ)e+ x'1>bp&ՕMXTT݄ R^m3ir  --x3ɤQsRZm;c?Lnע uaWt+_}"loˀn[[AźzO]~zwdp^wA/WP XlĞu')[jFdɼćG/Eȣ^-X:80)甎0ꥋwہ,R2%o~(~>.te'VTY Hw"UfUa*U&({KhԔT$J ڞOlL4hL6l2.neORO e2Ȼo*JS`mGk%ػI"ģPxޕ$K "}_y9p(2yE4]SA_ _O'hBM*.-6B7p/B`:>5.{Fjmxj4(p 6uu}K0iO\UhOxxpU\[XS CݚkV0 cʅZ2n|HG ߩp`~0`Aѐ8U0HY MpF`O+D/ifQxN+л鈆*,B9g”]rOѹwfsIJaS=-voX%H>>CW5X[]j/bl>T)y"ɴ.¯;|?bA?(泑$D)"v#Dt6ªP 1wzM{ڪjI}Cx+`Hl+lvh1{i`l&si 0o9,!6Ng%KDfͽ!w267^.M6X.qP7P-Oul~>3ĉY3O"k [7>xu5?cӒk8PB}THoGZW@2+^t?Elu7*m6~3c.,4_B]*fNaʲ$RYGCȵڰ ]]+׊jqM;%wm|#]F΍q[|eWuY@HUA}R=qKTB+HQC8G6~8wtpx|~4[Fwvv:9 |ϼ+H^f˿8*0whxl%]i@|1_o_K~7it jz !~AcT!>J0R}sr@׶ϲ%mQTRҗjսT+}Vǿ;͞]igATw9q'zJ2uu՟/8l_NϺvIj FLʳi>(#13H6@{pt__%PNw4n|b`򠐥| quQ̕Ch ?<(Jm._#>eбl|cXttو)g^`T*80Gh\ L=}5 dZ(iB͸Pu.u$d&nGin()o)ͲSqzVq1&j '1@ϱᙎ]͟;CA>`>ʞ)Ѷ*ن_Ūa@~c'Rr u"L~Gm[hEްH.%|+w Ol s$ O.I3!)u|UA:^' J&"S> L[[`ŇM}3H*ukK-&n!N3edt IbݖA?ͺIz}DE-TzIx-}.~`PǾ^o]n/Ufg*hA]3/v.J;}Rv {#K 'B]-XEL+9]V,daehVU'2<'[ΝvA_&Hn~sIVnDmܟ_6"`4fdow*T/3[NٌS)LH2.QI%.7m5XGJ !^(O$tӣcu)Q+)LrSxfX>R˂'!D 4AkӋG.6?HB˥HQC7.q=^Ӡ8S [t\{ZG*^[LT w@&"AZ_X.3V@k%Fw1 ~m+"4<`7V[}jn]m52({k\vrf]ap|zsj7oCZc0MIv_wy8}~Z O,p=Z>ZNчߘm}BnM7dܝRqxB\qfڻAN0ŽٛBim&{]e}4wҥs˯EqXL]j/kkp0B"Y Pb$ý&SP. WQ-L"/1]#46Cж4cIr;pKEǪQ+&+ҏ=8GfRd?KhQC7on\+ķR^^ݑfS:G01̼k-b|Y:? 9!.l.nz0VU_@du,S)P @, |>+%\"zoJ$7c9Rajjw?LG#M[[dd֮xyf#DXQ৾Ԥw$kDhѩwbW{^13FM\iU"v*].6z 9y+T|oϥ4ew#ސOhOO]*2t)B\;xzf`[j~7g{O¶LGJG(qHP=+'tstYW;|qqYI|MwOϓ{Q}>y?sn/U#ſ?\Kq.3Lصgt: [.7Su&r4D2tF=UZGh6uP5VUSev1؜d>Ms^&J,n>|@6ֳVIޕ +d P NYuH[x2> )--wS~A,"[8Ъ#Y> ("'Pj0>w߁[*e3l\Ž4c= 6li9)>'O?S݆b 5@vN9[Mךf}Z?gujϞǫz_P/hS.NM O\T{70U\Z]kheΟjpm_x,ޫ3yGScde]VIiQ^μU=GOPaSrp'^ ! t!gMT!>og%a8*>/@2} >!o62Cɖ'ybԫ~'6`9ShJPe;d6SBJxԬ/`B#Hd`9'vǕx&K^}5WѸZ/̵#ZRjױlF+`NiPBgp}rqOVD*C9؛.K_Z*H\ԹJdT=z(j/pr7Bl|AN.1 k#&㋘ v򼼹-PE_m^x[ ]>4'-BS,ۛ̓H&4ZA x>VCۄdI eWɚUٺ\h}4s4 {pt{Ax!> `6# 8Lzp,PŦc/U?mFGE#\<6imc,inޚz}tݹYc3nv$: 餜.ԭ˘9NeOb աZw}OgNm 1ie_ BɔEG{O9=ua`#m=M8n# ;;MO(M)m1WI.<":_ dH5ɜi^hcdq&:;n%j=eR5]0E~v6C^ /AS.J7޵WےjW]r*"vT1)'X mV|ukqN)˗X`^րo:̹0jrKMU@"_F&ٜ\K@c6˟ku=7@i:j(A-^ʱ^0YGX,EB&_&*`Lb[L7=҃UǬAK3bxc_FEźK5wJ΀>U;?=zXi>|X*v ЄzWllLٙB3MG/eN&4hRxEzo`B*Ftm}4zHc`ײ[Y'7W%fyMڬnS ̟ZgV嫟{A<^Gvf{٨h ZإXx7W'E5)V#vӃS1ddQ:hflGY}lm_EZqSuMȧ;69EhcjbԬW,~#IpBNr_}MJdwj=g@uNֈq窊u9/h 6sPkxр_кl(aAS(P1`FJTOo SZ]/]cz#0FVH랖b8GWP{:8wG\.C֠vDVKTVrB`%;|T4vQ_lewv5n9:ռ­U/P[\ Dp |M3zMdIl>_(Ęqy_ JT^clop`5ѳ&Z r1Za1cxUoZ G!TTu3-暲h2s~YMV51DR=G(5T^ΉDA  ~E/&W_j|sg&592#OXφN(D`Ny IfTw_!(?<$=,&2k0nNJ4a.e`\bf^ hñSБ^_t\"LIZF61J] wPTފ\>8X# O&}. kqN48ۨ;Mf91bi*(aҶDޏlvlGm1v[khG}!o @7;&A)0=BN|+V"fhNfUtU N06\]䟹Q,yPq„B)Km~3<cDC+nBXz 3OG,58WKlRר>j;oZbDdrXGUđo:O 5},}96d8]wpR+MFg<,u !Z )&wk6@ ` e27Q-'HO筭'вo) Eb2]wn!I=8,KkѩڅYr'@!VjhTޑ ,I.:?-V.Qyf^OjA䣰'GLHuҔgXz~?n̬嬸q ePo$q^`OG,|\Ո@$k'Cd.嚇b陶}s?nF/u[hֻIkj蠪 ;Fç}"u4 S A g "@:4m7d)M"Ȱ%*H~|^C}:RN$o_>SN%V ޔdFiCP-vBp}f9i4C?ZǾX5Y+`mj=PsǫDG!q,04/pkYJYBx0/^mQw1C6.l9LGZ‘/szc2 #7$*IGyRJ'99$4oMueBI-J{i/??.'xk#Y/寵ݯjI?߭zSKoH|u ͯWϕ\k_fww/k%yI(pkyB.o6rͰ&p+|#|䟦3l]C${(Њ'U SCr[BZD.[3T4e!)6VxkN\O]znD v\vM ,|w3+ٹf*s":]yqA >Q6HQ&u.LU6w\tsV R(ͫiRaEo9\\9w\蘛~PS^WC Ś=I< ʌe,yooUVI"B|6Cӫ jX`IIjTGw&\v1/T+xM&GAh_QvaEf묔) #Pd]6/9!1{$ ]D$g^m򽆄q9ߢm/[`Xh]|sF~[kUXyDryP&(=YS1`q|(#^?"S9WC*}]guys-U͝" H H 7 X mX Sv)w@1 KxMޘt߲2p#KS\f^غj-\rY"8ijw oN"}e"T+&Q{7ZIZSЕ~_b[,mc{1+&÷h ا JէLE7 rm#}K]Wp2B\hhe|)ܷrlht3ΐ}$zd8  ,G-ؤ62Mŏt=_LzUYǸ![ѵqlen\ݑNjkӊ2j{ޒڳhfN fX^o`Y9VP倄w\~Vq^ZU=׼mu:ѯwo/-NkN}2:"_mO Ֆ. ҂C=`ٳ{ٳTO 2 ғ7a\W8YߓԲיhcPY)] u>ŗU\Y 8*qw@U9rtrΜDO]NJhs^Gkyė:pWqAAeն HкK?cg*exb(+9T4<h? C\OGBjb|z-v0D'^%ۑ>nTGFU~a=/|IX@ w[\NN&!iՒץ|umHXO #3cZQ{B;XXkM7nޞN=q}c"T3YSj;O0<>5sܽ/E]gHw9sv~$0\[ <pjg5~-< i aA lG#⭭M';s8hn?7wCUki7chIH=h4NX'ök`GЩ媾SOA0Woh t+.oJe!.b|MVW¯q5ѕ`TeQЍcm =>fIЁ^Xb0Er`sY"$U1x!Z I~-ŧW4͕JǓQ#zh@oFڙi2Ak{kBդ V$/el$e8ª} UV8ΌPԯ&`_]MNhm'V1FǑE%jqu~IKCaÉvQ,$Z;ф;[KFwümŤ}hi\*HPĶIO=kRI"}$t/vP.k"$x>!bEd}_Q Gҭwz[q̓ѶSi;ڐZD\;xxlOZ:5FS7FBC'H{kQeտI~wfɯ(!N I<:.+ׇX2-v3@<<ʁg6_PAEp9GU&0BJw޻:zGǽW'OuCyw|S|e=ビ\E1xJ4{r"Ei!`ʫv_GZ%Lb6>ˮKE˚DkMԼ;z <Π4 9~Hta;`Ƒv~/u AP#nBwaZJA{"dLoY~-"jEnBPOrNB0Ka3b89>9>T`1䭵TIV(Qo),k272\[Wj%0xChl~aADX4M顮qAu@΀罽ׯT_?20vjПw'§mK1X[QU1A/=X+~=I =>% XK K-Wd(4(As; kZxs=B`[pэP> K/ Gt*{*xDR+EZ)7Ƣ|rY04I1%+^ذ9u*2@୬vN9d̩7o\Iǭnؤ fmrJ!| ]ԝqIY)&7Ǡ9ZR66Z2۴LUb3"\KbC8u*@ ]pzOr6g:uTr1?K(cQW#?YZ<zAk dٮf(S6a=841``j{Y>:;jlؤ')Ae}xp>w|rp;:g-ZkXTj37MXcvR~tJs,4Eh6pq(zh17b^ڡ6PrlÃSä3v0tU 32P ͋KQ6YQqbxK0+PJ!DM+NHFhdyY#Wh&д}[ 'dѼWs+?D*n . xd0%E}G *PJ2CJXײJ0;.,smOnHn+yoɛ{{6gIl0]l'ZUQ8م6LlI1vO& H|l M~Ip*g|畳PPyKkg4":Bۯ*fe߇(`.?RlslԿχP5$" lb~bF 4 Gvż 4VEqT,uݒSH~Ãכ)墑Xsp?ABAdf,{8Yͬ?nE/];Jg`[]%B*rw7)3'L3b:g5_-Y%{qʰIy7{"yΘϪrV4qcᩉűsv:Oi7:w9OwuMuM@)3P[[V w11BV9(:Ӫ>*pLђj*.Jiӟ8 ;=)g'b&o5y( N+Otf!oJ[!&aL7CDGׇ@ۯ5bo4usGf%X=٨Ӛh ZqA&=/עxV9֎n]F8y]E`c&Z\ߟJfћoO8xU}6@?A{9?@ 07lfLb<a-^)3{XE4,f?L4!Ъ* Yկ>6e/VD2JQ~Ϯj ғ@E < H u|`:e~.@uꔳgwvvq^2iz$7hԃAUQhZx׀f d]ujn\]1ߦ;;q|Qt[[ %/&j62p8lgQ.PYRՠ;@{7B ŸVV%Vf*)S<~zS 7yOQ=H/pDtfٸ'𩦦BK[zdb:-g}0+a;U(m҉Y c.S[􋈕'[B*z]L|k."0{[|G4e+%1|g $KN!s\yCE8iT:A["Fp_} SD.&3 ָ(.B-=`U +ԪglmA߶ ׊n;}G v3#G8f#%*\rOvG)`?jo!188"oٕiKP2/Co܎EE9R=I(㫽O@;=ٳxVtD.`Ҹݖ5|ҳoEۭZ~ARzm:K5*5?0/<VkҾ0h_4xev* -!zwLvD*W㍥0G\M܃W{^gR+J>Ky62Jq$5OY(m#i9mY: )2(Xl[K/:V_#-\! JhD+:߁c2?7<`k! V_sҫ| %M~{LfrdzÈaTksPZ.>~g&ϯk}AM\f,*I"Z&sC/ZMlmjjM=p2ln˵dl1@#2#"ܻdMpDk<|pӊ1^۠2W{)j^)޸8wSa¸KiWLIO`LH0&;VF T?4ph5pPIEgXPmcmǥsǭDcw)G*8NvqݴBUFŀNǠs$SutoOϾ?y}HpENܐYޗ''?;}t~ƒ{ $dx|TF3r,!~@m@C\ۗMjA4*Ge . 65b~r:).A~ "& +Fagu\gM}B}{ޝG6}rV[[ևgeJ)fWn>#x:|l}iZDRR3aACYlcV%K_Ѕ.j;!TnY%a{AZ:Vz"r D:}9OO!}nP v6[,I2Sff+22"2U|Qm(sxtן2DE@Z¢!131 zqq8yի>~s{uO/@pсu8ك7l@)J>Wp8F[t^{jda_\w]ɝBhz8b#ZŨ 14Sm<*q(f]+ˀħT%NG/ɵblE~8^aۉS$|q*º!ҡAP0hkP +YS34Y=5Id< juG{/-G7)Ol˰ V :pmE;+Dv=踆!< Dͧ]"=о7P8 /$]kSѝ JUK1 .9cCn6rLhoŝ9R b3Ã-~2o>+_d_/id;_raEF$W無M򷄚^ؿ`vgZ_frShkkY;Ts0H Rl\{<w;[he{6!l '-Lܰg_H'}%k闄BCzZ/@TG=hz 05A;s/F ^;~ݕW1F`' 8 GZ}H E"h :oĔ~\esg$N Q݂3x:rd~U񐴧d4jȕwXGD9 qLW)k$BC -thwn#b5( U]]Q.0/%(^3wui/xY-%"iO/v= nɌ8.nf:ѿ! :k$puSc _~xCv65:]%PC!64;Df_*/Go޾?9)ƻHb֖v r  L`^_*Jp9bp <{f+~ Z/x׍OǺ.'#8gސs]'^\~AW|9x9]Zdq\qGe&Yx/bl#q~ŀDM3 /PM=0򪴌7/tMMnB R4ݺQx2͝DC.%]{}9~h7E@G"4kaxi,^0c텳%.m1"JD. K,V0V i-pK(àƂrX߈ CZf|xpXd4~H#F7E-SXb ,ad g59 h, | ء2V@OTw~ Br.$ G1#6Rgh O#@s E凧2l>&s |"^ɀK+{OLaP;t^-UojS E(%0{Z~Il|6m9)91P W_"nLO;ǎTG>~#peg;͚#o-$ 5M,}@Eڃ#ْ1Kprc~EȔ8𸣑BI8^VhUU MM ^ؠ TKzc<?ٰ̐G ػAmnʆDNixڏ ( Qfl -MJ8g_'Ë(Y/nC! [jtW{ٰt.~#Ȯя‰rk&jd,='SXA˿'֖b50d…/VWst/2㘯 "A!m9b>.NTj '#ًzz>Xj4P祥LXS?1+ Y rģVE/3=~ PG(PG(SYhp:ίN|W3ˊ꺯t911n LHDrg`2_Amu,)drЕ_GXK+Wl @Npojh.a)P} o++ԫlr _Qg /-M>j^ձ98NS6w2TuI!ot# ؠ˳tϨp8ݰOE` /i;ʕqjHC@ȅL-%v6 h-!ICN]r6ăx^<>]*W%]@*ul5"S3ሖU ,i!/+.NJȁ]HCF/7"s+8~d{;Ŷ}8As+woj^xUU'}u9{Ff/70)ʢ++h0g{N[Ec=YCV*0Ɨ^}JR-i)4׏E=g y.{O<$щޘKNQThc}W])tC>^Ջ*3Q_iEf2Lp^ ';jolh';/zlcTM{<I qCt: xk9p}4HlYm׍~˓[Ȝ8.ծ/m}QՆeLpƠyY{Ggy'^Vkp\'b=34bkcK񢢗3-S:L~|dbкKvWs[ n8A! NW\_zқ:f"2A$oXvGu fɣ/WWX.h`Cic< ۊ@b@3w} )՝tF+3H\ }8aH%zE5mZU3ژ-$qG4~lr5F#b1XI֪KJ5B}-ĻjR\EEsz|b-:έz@D@M H\ Gї?T9X` ^Kyt|Rh*jAZ72|\??7wo''MGQKT߶vwմ_N=?~#<:88|̚;mbI!z=:8}Y퇗GzyZO{ώ\ dYb|Y7/ofJ=!d?۠bp-$F$D(!QMCpxąM}8h ;av$8 uxtc֗vXʗ~djHW $Z]}LtU+Cs dOSHjN _jKNךE c9$~6D>N $+DTACGX)HOUo9U0MiaLPVFW+Otwi8)5E=UE1SW y^![cw 1 oWéyƱ[FT%,GM~QA$<AwNу;rZUm5}.i:?ەJodağal_;~r8oG PɆ .pP(g; z8TcNtJJj3 Ԫ~Ov¹gxX2 T^yH1}vPO? ~د)4Bens/ j>;g[euUvM\,ru.Kmĝ9`",o.rCG BN1EurIZTYM*aD3mZF7jڽry$ w"$Xk͕8[ $5O<:{ a9T6=2q~TJ7BiO͡~ 9rR(%rV@3iސ+ k/so#Yu%*60ngdinwSC ~l`5ѶE s&^c=Shk HXQL^C`"d¿YLw2VxCLA5+N b~RMd]S!UL̡-zcْW,\iԲ|6: :5ԇ䌉ӈM_?fL^PgP_T.uy@uvM^jmH 9۠\ΐeT0n'ίfHӇit6icf~DžW Rۢ2t7qÊn#ŜlcV{wqu8fDqF^~Z߲7tQ!xH.2B>̣kρ7I5/nO [[Ct~{A^L\ux;fLݞa4WY6_RG:葏|ؙ:RKtCvZ'B6!-Snokm0L`:= Kqld?6_O8ћLSɁV%xjtdNP]؍}덓el䙹zFu@o0#БC]BGZ?RS"܍ N@ .0S~Bsa’S./ >$Z tE^+|./\[,69y4Dʟ{'vu3TDp -DslN|ŧ#>|"nÂ0վ]$2M3@|QvL"f7ҭqSq Tht-\ KmY.λ˚ ~aɮnS00 g(˿CQ|uUqU^=RiБ/YE>•pm8e#W,BP zEST yXCREFܑF$r6uDfPdV/GXzĂ8yk&Zsɦozk-uCCZ5dc(9ϐs]BLrf_]3 i$V&AKݟ֮pnᴚm7toJ4޸`> 1hSH"V `8>@xHs>iFq".1F[ 9hd(߬m#ǻ roaH#RbےS!aE:-s_bt;5eo4\UjsJ."W'XdmRMF/]ݲ?rQFWj@CD4FmՓ#j|MS2ȥ۵W?K:j z5:{3L4*Gž :II= zY*?)ZZU b1&s%\.0Qv-3tXYzMÞô33s-^J3PTTkoԮ?`tʽm@sp}ƍI?]cmcs:ӯƑTJ/#߼i ^PW,}ws W7\1$(w9s^0t7HXGRS G?GCwžBFXڍt8[,uȈ/ 1-Ri8_wsG ]n z Vy> }TZ - 8oi8)[ ^r9ɿSVۺ?̽[_ ih?ycxj3KrB ~D;$fQ窋⺞YKf5!@ȉid:/\^aNGD$T[Febd\gPU_ -֦OU?ӟcz8(+ _gDW$&K*->48߼HU.e&7G0jhv%>P(4Gg}="7,IuERgP?is!;&rVTKw6KBK+б}b"N]p. !Z{;W}Ɯ IڏFq)Q=-b?w `]5GoڲG#G5݅/%$:~{^}zϋE "npiɉȚ'J-Si1rON#4WKV-_mQo:jcelk*R"L eYifdq > b 6[WiZ/IFv>&U)x}]5'@Rkiv4$ j Fi55L~D8#@DHE&3 jIb fQďm8>Nlcl_tth'""ݺ:ÁC3{*~FHh(vܵ\3`blLd 'v=#_9!拸YebeE[7*ǹfj/qWE2![7P)wP j;@.Tm2"u WCkt?Qջ otdk L 3;ǁ)U/Z*h)^6OjxLojsPꏽA ޫ8-9$mY!ޑOpQN\ꡂY VfZ=@צNi4ΊS74R%iS V}ռUHq=}L2m@l'Zsןݍ/3 V@݄gj;[)_LTUs6YP<* q9!H+_;q6ԍwad ˍ1geue"}|y,2n->-7;d0_ ?Z.HRH5._.:s}+R^0iڲgrAn?SIeFUЈ,өG%xUjT"^^?{wOwGޟ0jV7Qfj9fcLy|c)~3""Uنo; " /nS+nY^Q餞V=(u&93;M 0X֒7ͳG_xTZLѭyZu_7u:ч ?_*l@|K%V8I __"-#9WkG9/yd7WuNwA1*.ѝ!o0;ZPL.>_u~pR7$=XqQNG'eP}qF bZ_&{s3dGp`\ ϝ1`|HfU9vgvfOG{kHWNKYdP˂z ^}K`Ŗ7Ԇ.lE=+2TGC?:\cv2Ly6ϭgŽV2hR͖/唋AX9ӇA'w;Hu+.G Jk1 WƮ }^>tc&yYZae~g`ݟ0[1jjNT "O'_~c&?? >༳<*B@hl;>j"K-xߥ:F&\dᶣ؎ucIy{b#m}x NLDNHHY~#Z20<7V~\}CMuZɯo'A`z^_֣v:u=(lj9# M8X` k`wl2R|`^~}zzQx ;q&v,U_< 7~XI[ .k4EHITVK]zJ_ _2FmWqCfA3e/Hl66.1X@?YV͟8&GwR,ŴwSThJaj@r?hlkKN;I%3jfb̂z=<'薕){*njs&Gs2.}+ )|zt/Ո-X*2^LUVS7 !ؑy:akcQx™c#sl6O"-# 3qA sbs%:j ]ݵ\Xm M)*i7r<^M}0peyFdZV9}%/e` Zj7QȔ#LDl =L0T#w A6cY%2$+ ]Eه81!oOٹ!;PʺC%}11 ";(Ma-+30 ov2k~vSҚ-W#[ N-@bI[Tz#lѶMhSꨡxܺXRruj(%eĢy',L:ety)HX_N[xy%U’&xIH3=.tiW=mrq!XR U2 6QB4[WW|TP`u`?޳wLǼA/{-Nm`qRڿ1QUޯ|8%Tc)9`aچD}(sCP UP?:~E7m p"q8 d7JQ/yKvNvLqyPbR\_yTqA끖^̮e}F*Q90Y`c0V)QXM6ruLFyPc)ǧ,gOM-(qy#5)hQ^p?]l&&=}&JG=yb\^iA3^(sbeԗfXU#0eGimpw_Sb{J2[ MW3nlX' a~ efƼCSWݸ4 fMN3(?v:ef XBa&\bF&ʳZB<ʖ;ĞFiD}? | 5Ũ3=Zn0*Ç776#v\@|>%~Ű ђ$xvS qո4Ke^G9Y"%!8qK%`  xڶ JQz2k B1l2 +jtʲe-˱\ܔ@4_yً`$p675dVQQBqGax ! @)>Mm_!ctt3 ujT]Og&³W;'KqVlNwdR<*o& )n]+9 $ G wzT:E5R qo7e`@+pzΣV\y& 2E-LX&|ɑit$m]lzE#>GlO< #`{iznAOÃn^G3tcS? jj,oW\ eȊ|_…R\ Ym4Rzj ZSjdiXhܽAe1S7m|hhJYpwAϪU=z/aI5뇜ue[[9AS WFػUk4Q@8*s,WEz69 腊N-%h}5Ž~f2oQ h %OXu9#}3:M%bVYy'uW袯MδܵK}pZ7PjKkiHF?+\f:r7> gF7^A2zH é)A;L aor3I񪌌fs"a(v B ʝ1 #L %يoqGZ[OL4ݖ1AI ]ZXz\1" U:+\;6ӭtD)ng7ho˗mNf/ bS vzv9 KV:]*ڟ-&N_ 6R_O(NͭmSPZ{a}K `{,pսiWic ΅W wn( __Z.: - LK(pC߬Y~?- p*^0?t[TS/IcJBȄu:#ە|hm:=;_PQtm˩&dmi1a{}YYN(n_ۙxm05 ))l wۮ%H eH$W%[5|oXÍ!yP.ygVhTǹ:}ptAeY!p+*.aa<1FU)9V*޿p[Գudس-g~sw"g'1idVqTM-{B `AsMϷy|DL/z[B~b}#A,ojדp~9k?_vp?̲c_CN1՛zZ$k %eY+-?aЕ7yU<E0]YTqRۃ!&W{u6IB:I2ڲ\QX)4$׊i%mr8T|s%}LTOIId?> `IU \kwn>j:HF)|]*d8s7g8UN +RKa$!#bN 7,̫/jsfsY<?WsiU5ل#^uRzTx^YgM`~ccLb:%TH9{w||{s|p;:<:* #DgꝜD{upN?=sw$ޚA{_'oY]A // Copyright 2016 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. // Globals: /** @const */ var RESULTS_PER_PAGE = 150; /** * Amount of time between pageviews that we consider a 'break' in browsing, * measured in milliseconds. * @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000; /** * The largest bucket value for UMA histogram, based on entry ID. All entries * with IDs greater than this will be included in this bucket. * @const */ var UMA_MAX_BUCKET_VALUE = 1000; /** * The largest bucket value for a UMA histogram that is a subset of above. * @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100; /** * Histogram buckets for UMA tracking of which view is being shown to the user. * Keep this in sync with the HistoryPageView enum in histograms.xml. * This enum is append-only. * @enum {number} */ var HistoryPageViewHistogram = { HISTORY: 0, DEPRECATED_GROUPED_WEEK: 1, DEPRECATED_GROUPED_MONTH: 2, SYNCED_TABS: 3, SIGNIN_PROMO: 4, END: 5, // Should always be last. }; /** * @const */ var SYNCED_TABS_HISTOGRAM_NAME = 'HistoryPage.OtherDevicesMenu'; /** * Histogram buckets for UMA tracking of synced tabs. * @const */ var SyncedTabsHistogram = { INITIALIZED: 0, SHOW_MENU_DEPRECATED: 1, LINK_CLICKED: 2, LINK_RIGHT_CLICKED: 3, SESSION_NAME_RIGHT_CLICKED_DEPRECATED: 4, SHOW_SESSION_MENU: 5, COLLAPSE_SESSION: 6, EXPAND_SESSION: 7, OPEN_ALL: 8, HAS_FOREIGN_DATA: 9, HIDE_FOR_NOW: 10, LIMIT: 11 // Should always be the last one. }; Ur0}Wf\:y^bc:m;|iꦅiwզx%lIXMZ-gEaBEZ__wPf0fs7OX+4ge-$Ie"lV0j ` @ TՐg+1 TU+-2-VUah"8(xZ[cћAnjٛަH`*eosVK!t1ݚ7g[#rmjXềlZ^).\l?ZIcaXK=gY͕"Dɺ]"Z*Irc64931cY}R)ᜎ3/M赆R?ؚcRPAƢ='qv$یG `c?`2dZs7B[XHJ.;c3*ܘTh]=7G}[NsD}bB1&󠍷C9g-vSzsnֿ"ӬTqEtpҥX$dn+qCnC1i2Yz!)H"}j͍ ! +9!@coþ!iKd:Ģ>͒6goجYKRi tZ԰Kf~3`,l+ ί1 IwT;AR?v]ΔkUS1u#// 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. // Send the history query immediately. This allows the query to process during // the initial page startup. chrome.send('queryHistory', ['', RESULTS_PER_PAGE]); chrome.send('getForeignSessions'); /** @type {Promise} */ var upgradePromise = null; /** @type {boolean} */ var resultsRendered = false; /** * @return {!Promise} Resolves once the history-app has been fully upgraded. */ function waitForAppUpgrade() { if (!upgradePromise) { upgradePromise = new Promise(function(resolve, reject) { if (window.Polymer && Polymer.isInstance && Polymer.isInstance($('history-app'))) { resolve(); } else { $('bundle').addEventListener('load', resolve); } }); } return upgradePromise; } // Chrome Callbacks------------------------------------------------------------- /** * Our history system calls this function with results from searches. * @param {HistoryQuery} info An object containing information about the query. * @param {!Array} results A list of results. */ function historyResult(info, results) { waitForAppUpgrade().then(function() { var app = /** @type {HistoryAppElement} */ ($('history-app')); app.historyResult(info, results); document.body.classList.remove('loading'); if (!resultsRendered) { resultsRendered = true; app.onFirstRender(); } }); } /** * Called by the history backend after receiving results and after discovering * the existence of other forms of browsing history. * @param {boolean} includeOtherFormsOfBrowsingHistory Whether to include * a sentence about the existence of other forms of browsing history. */ function showNotification(includeOtherFormsOfBrowsingHistory) { waitForAppUpgrade().then(function() { var app = /** @type {HistoryAppElement} */ ($('history-app')); app.showSidebarFooter = includeOtherFormsOfBrowsingHistory; }); } /** * Receives the synced history data. An empty list means that either there are * no foreign sessions, or tab sync is disabled for this profile. * * @param {!Array} sessionList Array of objects describing the * sessions from other devices. */ function setForeignSessions(sessionList) { waitForAppUpgrade().then(function() { /** @type {HistoryAppElement} */ ($('history-app')) .setForeignSessions(sessionList); }); } /** * Called when the history is deleted by someone else. */ function historyDeleted() { waitForAppUpgrade().then(function() { /** @type {HistoryAppElement} */ ($('history-app')).historyDeleted(); }); } /** * Called by the history backend after user's sign in state changes. * @param {boolean} isUserSignedIn Whether user is signed in or not now. */ function updateSignInState(isUserSignedIn) { waitForAppUpgrade().then(function() { if ($('history-app')) { /** @type {HistoryAppElement} */ ($('history-app')) .updateSignInState(isUserSignedIn); } }); } ExifII*Ducky9Adobed      !!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!> !1QqRA23Ss4aBTd"br5“D#C$%1Qa2A!"q# ? Ro #uNr_M0<4H:[M FifrRZC?w9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>sw9{~>s̍muvr+uy:<ÓѨw;7TP(͋ܲfVB7F#v2DMv]܌E,תQJ9w.ՠ,=5^JW&h5|GLk!eȩK-+:YgKTm UWPUN4r]qxC%T2]qxE:MrXŴC;(_mas}Mb-o69& 6ܜQPU-S};bVu17ht%I517h$ݢtSvRMLn*7AI517h$ݢtSvRMLn*7AI517h$ݢtSvRX1>+uy:<ÓѨw;7TP\ Eifd\*7ELWy.tHo%=r{GiKh&U TSWRgx-\pH:%n'GXrz5GrFQXR[@M q@P q@P q@P q@PlJy?]u(2j@/Y ya@>f&% m@[}j9=#4¥ڴEn|*q)٫=L !Zp3m!q jxia~;WWAqEaH agڳrPa<&9R[g6'X6'X6'X6'X6'X6'X6'XJNIDTcczhFڪR6B<$b7^Vm0٫J-JN&LL4Er-`9St @*P9w.ՠ+uW0/SoN]g1 Ӏi TPĭKOFB\zS+ @Yi/ g*Rh[UIXjSxDjl #.B˫P2*l ,:hZw KCIuG*br?W_1Vk!R< r䶋JQG%JP S+ZV)DrRpޕj2ZIO; jM7f3֘!k-.-^L^+b#m6<$1fWY6[i %Tc}Vty'Qw!_?=]n -Ju.31+즛v"­ڴa6-9 E'HEhdY2r(#Gn/? rbbлea1ϚU->_@e?-5;'%7@%MЃ[BYUzRoOx<~0v[q} sxvjS=ik-/mYlΊ&lCJ3{? ЪZUiRLe"6.)3|1Zg$Dw-;?wWyܷd'`^,7@8c*4r6(%Ix~/+uQ1IZ9@!V5ޔrRD$U+{p /&; jM7f3֘&S"3Xۆ>obB՘֢jl]SO+y G9x g/na>ղbžlļy]z{ZN!'5?r)鯋X Dxkך__/foGi5} vsTxx~/-DWbڠ(Lˬ5g1p\f#YulԴ%qxaRZ U~8?zEIJU٣~uN&')b7enb4bsHd9wM?G6̤EJa=dnWyOCWLe rb5n*.vM.ez܊"cdI~V.]T .@=*[?ö?//޳Ceè[TT4) hL+*Ҩ@62j3-R\P-˜rk΅ աBP7%1Un -]NUP.4Y~7姼d~f?TVh_N9e;5tO穞Ri[*4cfQsrҪb|5Ȋ !͋i??|ae$Yjn]uTZ?|(i [NK)*IT޻'uOK盡, F:X+M(XSp˭ŗ_;OUs!9UR6WpdboLThW&g4~^Qq\S+gv%%|H{Xu^j+a-2R V-@E4-2EVQ)HRvUDt ]Ziv \Z{GiKh&U TSWDzL@zCjw$YE^sk+HvC{~JmLI[@{UB5l=*޵whDfQe'<6(2ڻF?b1ҩ;ck>$=^[:} ŵ@K m/AlV#Q1x%cE@3lE9["9V%p--5h1KGZ RoOx<~0v[q} sxvjS=iU)[Hy3AF4U}Դ&|ܗv]ͰWL!͋i??|ae$NJfdln#3ݏ ]2~ܪY8ϡ*~<YHS+gv%%|H{Xu^jT6Ćj*@"D^.~7姼d~f?TVh_N9e;5tO穞5զ82þ+~ULl]SO+s }3)'N_; wZ'Jc^vWvD5= sڀJ'VKKyn&:3`*F뽖5 56/b7TZ},m\ 6ˇeiqxaRZ U~8?zr1!}lL؊{UrS鬯DQ^.sbr~_?ØmI=_WЋ> Sي/Th3P5[7 KQ=;sh9]R3wު%>_;/ c6I1oSdUac3!= ԣ1Ui//޳Bw6cC1mPx)|3o; jM7f3֘ O /Ռ6d9wM?G6̤.ԫEhǧ\z%TLRZ0QgrpZisH9hkK@dzω_)[:} ŵ@b@@mqxaRZ V9 ʍDWRa9r)٫tIw*2c3arݻ4%`˖٤+\n$6X2vi&1J-۳I1V nݚImevLcl.[fcdrݻ4%`˖٤+\n$6X2vi&1JC˽ejc)dk;["(gp3K徥)=徥)=徥)=徥)=徥)=徥)=徥)=徥)=徥)=De{Q-opzӎ5s&,F=' uU \zDG_S'--U#̽$}%t%%ܣ| 7V]J\.^t.v ?bƤՔya+[ )6\!l_f)#׀7x~RXu^j5KJi[bf^09w.ՠ(TVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀ5mVۀf@.Umr͢)sZD|.z&4G:m9UxJ+ 3\߇%P6YjY[QD9ņk\> h/6µMU"W6K7Cllj dzω_)[:} ŵ@nSEn^#}xkj"P^%4l jD %(mQ. CjqJTK*Pڢ\AR 6TD %(mQ. CjqJTK*Pڢ\AR 6TD %(mQ. CjqJTK*Pڢ\AR 6TD %(mQ. CjqJTK*Pڢ\AR zFZ8tǫ8nj0˓U%O>]sxrfz.M_JN+9UƮ"&ɴٗ'Pm"2 .:`c[! 5R\=ׇ.vo)jǢt킸xuU&wӆ68l =EsNpo4\um<ƷsCrjwQ{DNy:=GS 9E;VJ"+%jN5uȧr CSd]r)܂P殹AY(sW\w 9E;VJ"+%jN5uȧr CSd]r)܂P殹AY(sW\w 9E;VJ"+%jN5uȧr CSd]r)܂P殹AY(sW\w 9E;VJ"+%jN5uȧr CSdE86mfeJ7*@T|-X]TW⽋7d4͓6DY$m5R˛ؼ' h5o >0˓U%3~(:=Qh w qrGˮ{\RՏEIXpL.g0xmz2A1ʦٙEvLc0/M1U [@1c}a\.@TQUuEGȌ/&hop淪\r|#Xާ>淩'59qc{8Nkzr|#Xާ>淩,o5^wEWYM1mSƦpFĽ6Jwq,UoWoTI7w6I7w6I7w6I7w6I7w6I7w6K Jo/.1ͱ=i{mWX;;G⎺3uրo)@T|-X]TW3: U 0u118"ܶ oy0;jf٦whc[! 5R\ƣN\`\.o˸Ƽ x$˝_F{ybϩN;7Z.ɺ!T[IXA0c]4LT[MQdG;UUuVNsFnUbԚ ;;G⎺3uրo)@T|-X]TW5: 6}Gs[x5N9ewu776DLL3켜Wd-+§|,'Bҽ<&6дGO t-+ol J{'Bҽ<&6дGO t-+ol J{'Bҽ<&6дGO t-+ol J{'Bҽ<&6дGO t-+ol J{'Bҽ<&6дGO t-+ol J{'Bҽ<&6дGO t-+olI,bٛcm- 4B5ꏗ\^ټWұӶ cGZA@TM ݰMSqN(U3bqwyEq<{hrpVOS{ kw>:|a&KAI_2{ |gu۫ħZ@*i-ƅkP.9syKV=U&cl2-9LnF l@ES -7_7V z]7TN w=u5o >0˓U% ~Ǥ=]E zO ^N%8ԲSNan4.SZ=>uu˝Z躩5}+;` 3 swܐ9LH&w$n;7yɝrgr@93 swܐ9LH&w$n;7yɝɝ դŷRs@]sxrfz.M_JN+6]]M1-SE4Eŀ5QMqeQh*MqH5"QU9`LcVY<[3`o/8Y^ ^?H/fTi}A1|t0.MT sd>u{hd\4ѳ٤=૭/mSK 4B5ꏗ\^ټWұӶ b&f"1^1@)Wh\_q/(Oض0Lb3tC_G7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7QGyyWt7v46nkriLqj1u{DULWL8f&&bq@{OsLen3O@cw\^QMq0 s1pF9'@GdOS{ kw>:|a&KAI_2{ |׽l2.`{hـl}U֗tq)֥ w qrGˮ{\RՏEIXp ?lno+ַSM0˓U% ~Ǥ=]k60M=l6i>{*Kuy:8kRM;{}йMj=ׇ.vo)jǢt킸qi<ƷsCrj..몙1Lv:Z.&lLY=<m"&o)*xmF%wBPwZ5oLaaIr:8Zky̘kURl¥_-^NյWLWL?14R=5eò n|sF9&A\.,ָ1OEV,kw>:|a&KAI_2{ |׽l2.`fl1(^E8)ӼǓܶ4έwwו]͑ƌo}gX[8"] ']ivmՎtq)֥ w qrGˮ{\RՏEIXpӷyoC2Ir[[:+)fH h5o >0˓U% ~Ǥ=]k60DŵMEY{VqoVX2{^mm`%-lFUgq=uݤO u`Jq5d=h\z}Q×;7cvIiSӶ 6 E[ 7wESM ?{09Zfآd#:|a&KAI_2{ |׽l2.`D[8!F L}ȏū^mm:k2;=wF%}U֗[SK 4B5.j>fn"mS&Ֆ[WN,vlYgbVqCz577v(gMMYYF,"Ӛ b &upr[%XΕ:kY 1;0;GS3*ƗIѫq^U1oV&qD] ~Cn׆%u5WSRxū14)ڶ :]r;Z산 qom2L]:Wqiۜuqwu \[@1c}a\.@ c='|/^Ѱȹ W]Vf\Ͷ~5UӃ6G/&kզb4` wzS[?pK=૭/V ħZ@*i-ƅkP_]%WS;G /Z$M=lENq)ݵ\>at?݈sca:}bj8Uϻgter]͊ޏK(ٶPz{ 'NVFpra:^8띫!\9'.;z0]S*-/vF38&jSBtzzMq_'?DU7%=]eWυ 1yB6+)fu{hd\EUSD[T_|[:*^WNڛ1DjĸyO]n/87i.X'GMjYo)AgSUQ)Q?w5D7%Q.ۚ5]u)'˛Wk]}]1LDU1}Wٶ?u1)_poHCt:f?=\&zAz3Ke =N{W ސnL=竄oH7OSi~l4Oy6[ _p-د{ ^P~k;}ҟ ocn-^NյJ)Sӿ^٣눙{Zp: h5o >0˓U% ~Ǥ=]k60nF3f"Ʋsru\T_oUhZ@;=wF%}U֗[SK 4B53jW)w,2&w9a~"],?*.c@lO]gXg&'%QWy}'?ÙV@k/b|(sqyC]8 J|*Yz&;;VX,F 4*$DF/h|K4@o[@1c}a\.@ c='|/^Ѱȹ O-Z,*s@gq=uݤZeSNTce6k h鷶brx] ͹MN tO MN tO WYƈ-YJ-35kI./9UTJRWۘGZ_?Gs-ҨMO1Fmq5y/.i݊RMj^{PpYMCT{+qj M wv51d~q7U[hS +9<F(4\um<ƷsCrj$ۘs'˭{F"&bfȇ]TϻN zFfoFVc,k@ǞT#vŤ~ (si2%NL8y/^Hh۱9t1O渍ƃ6icOhHЯyQyLM3wzʧs:@Y{sCmgu5WSRxū14)ڶǜuFsgn**i-Us6'H-hT[]Ubِ]4\um<ƷsCrj$ۘs'˭{F"#TűY{U+g51\$kȵ`]*zqyIttbJ_Eg9h4t-^Wꋚ6?c?mا /otYHzn_2B0˓U% ~Ǥ=]k60EUE15U!b"W{Vjc8NIea0yO]n/87i.ZOW>FN"^"^ھ}QWe<M8c\qk0k0ƌQz&qDO)l+ ̗7Byyu{hd\U/ӊ1w+_4ie?2gq=uݤ:wi?_/yq:LzN tmUҴUfc=v'^,k4kTu]S8&% 0^E&LN~sezmeWυn/(kG>_OK7@ЧjXL_%Qffpd6Ss{V*' wYNXcA = 13ov*c-pRLcEV,kw>:|a&KAI_2{ |׽l2.` :E8ӽ ~}HWc7ǞT#vŤ~ (si2% &UFOa<+qi1Nxqsf*ib[pxW_^Dlq{s՚鵗^>9wSPe>,NJZBk `k-QlN@VA{Jd7 =;kTo(s׼ Bs2 F"vpdSM=DlE =#EZx/jp8pu4\um<ƷsCrj$ۘs'˭{F"1.隧j[YJk4Pq˃ut[b(^3^qn];شүY}<8&DU4jv9z]*0;.'7chfX,j_?U㵥޺TU. !ɫ鵗^>9wSPe>,NJZBk `qњAZh: .:`c[! 5R\zO_ֽas9֜N/}ߍ!U3^qn];شүY}<8&D:=\M?E5MQ$2fv.'oƬȴog Q}yʼ-&}%us֟.ԤY{sCmgu5WSRxū14)ڶ zD[q\u-`:5q`ƋX1|t0.MT sd>u{hd\HSŎڮmen+k5SqǞT#vŤ~ (si2% Wq&I&gGNsLK?5Tb\6e/? u4Zs՟İόMد{ ^P~k;}ҟ ocn-^Nյ5Dc888Fjsz9QTnƧxKm s@q6^ȳP8+MUs՟̰q Y{sCmgu5WSRxū14)ڶUm75ۋ't9@nA1,m6` t g\cEf; 7ρeɪv?nc̞_.m U{ >JF_UǞT#vŤ~ (si2% /b%sTNk//w1?qSz7{'u>w1?qSz7{'u>w1?qUaq^yMUTl&{hgl,b#bSPe>,NJZBk `5_cjf g&#uA9b$Z9 l_՘;/kw>:|a&KAI_2{ |׽l2.`(^WǮjp2tu(R@;=wF%ӽI*}PݣӉdK5UuL㿗?7Y'"l DTmWgP~7s6+cu5WSRxū14)ڶU@jlcF,mNL@@/wd5o >0˓U% ~Ǥ=]k60"-ݙjde:meIkgq=uݤ:wi?_/yq:Lz*z/c5UNR4l,2]k:Lj2y-mHdnzu5WSRxū14)ڶ]}&f:طF@$ r{AF0@yA|c[! 5R\zO_׽DE8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8fU(")ł\j.M!CO1}I1}q_tu9GW哊_z:,WNbd⿤sގ''7GSu~Y8?:I /_N+On1}q_tu9GW哊_z:,WNbd⿤sގ''7GSu~Y8?:I /_N+On1}q_tu9GW哊_z:,WNbd⿤/h-':X:uIiŤ~ yC <8&D@=Nvo)jF+,RjF LST≗%SWnQK6.o˩EqDӻ7cDUMߗ۸ue>_OK7@ЧjX o;IEm e3eLFMF @1lf 1w=_1|t0.MT C_7q8*l[fdJ-tF5_+Yn|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|~8no㚯 |sU~ỡjOW7t7WS>9?y_p5_+N~)wC|5iZW^Wwj뻮c\[3LctL~MG/@Ԙ..=5W.vo)jǢ֑V!M03,V7F+< FѶ`e-Yjמ9,ey/#(Ѯ̧k..m۔ 7n|T]9ɺU:4x31#7wVרnWsSE[M\|*~4w Ky!^7_o+U=h(=^r4}gMzUUU֊Rr*h1YnL$u5WSRxū14)ڶ1lLgl8v&s0Yp Հ:L80,$dY<ƷsCrjkD8z;ڪnm)fpGm.vo)jǢz@U;V։T]v?`4UF{V;2 5~俟7ժR iz"zLtq^+ȣb{1 -+^FyueŲ(屳6EMT+}yo.ƝU :|a&KW\RՏDNIi83 t ]HGpg\uƱ! 5Sijl¥[WbhSmuo NpkYY0a0l"-7pldlm@' ]ո2v_ZEk;f&bgkPwy[T(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(top(V\U4_TW.<;}`\1|t06MT[Z@u5WSRxū14)ڶ o"g1,0͸#pǰ.0&`F ̂6@`YA{V{ owo @{]AS_)hGERjV:v\lpc[! 5R\jl¥[WbhSmuV-S 1=`0[ 6FE8wA@2- qX ct ]ո6 owo @{]AS_)hGERjV:v\lpc[! 5R\jl¥[WbhSmukl 4͛@#t,q 8MF #&- A8DyA|/_H?ߋX?wҦRft킸qiYԴvyto'-1N>4f_fmJ+ʷ~dxϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;n֯:woƪjlgU!e7u^Y4ـ⸋fG3X3ș zGqΖ^0~jk/?FVcשڶ{G8ю:lcsLbf"&ṕ8]׀t3`58A0[GmNmX~]9s8k88љ}(+*I߫>Wc}'~>cO_},3?{~Y;g~v8ϤgqI߫ϱ>Wc}'~>cO_},3?{~Y;g~v8ϤgqG"Q4qx\smi̯&]J%muSmuf1],p )0d-j=Wូ -نΠ0V ru1dU \kʧ DUNPV$cusz7h}_zn8[lck7QTUX ئ&{iPE hSeǧ> "g r= =1́7FP"g(zg/\=6n> "=p9#sgސ93H#ɝ32Hd f0O3ffoЯ(&يg,fޙrrr4n^:fޞ:nޞ:fޞ:nޫN*4y^tn^'Lѧzx=7FO3GO#+J=3G|;Lѱrrr:foo<Nzx7/zx?/4n^tn^4i}t<tzx7GO'h6'GO3FO7GO3GO7FO7FO3GVO3FO3Fޞ:fm}hܽhkԞ:fޞ:noo<t<t-xv*t 7/zx7/zx7/z:nޞ:nޞGL{pcLѹ{Lѱqr4|?l}<؞OMѹ{Lׇ,Y<قv4l\}x7/zx3G؞:fޞ:fޞ#Lѹ{Mѹ{Mѹ{Mѹ{<>tn^:e +W&fg<Ǥ]Y?zd&mpOR@&ثzAiwYgzAJoH02'.:ܻt?G;t뎮>n\bøNn::u[w-:}WpqtNq[½2Ӄe@ML[`q} ` O(2#cu@&&qemDAgc˰dskdq䳪 @1d͖dߴ(ؐcv9vxZ{8`#(''x v O(&qdF@2Om0#'d d?Whm2@h&s cs2s 4} =`NL]2o}+F曶~^-v򝓗[׻|e#7?ϟN?/-Wv$/?}>uyivV$g''@qK(bCxfDyv;qV  Ԣj >Ƃ$=vy']yYQUdKgaIeCȎSL_6FB.Yn`4xբ3+#[f^Dܐͦ Pl[!UZ,sI.#ϳC0B*o"ĀAFsK:_tF‚˖H||tAL+"K"ʪ(`0EȇqܧF&sazEnNelj M˝:#ERd&Ddfibv4 ЏT"Qd!zʖEYnOgUž$ UdW`%bf~U(gTa-SݔaQ- >" G2r܏rH+vYh (7+Wh*@U`ID}"/Kd.6x2$s{yQؐˊyjIB0,\^% A@-; A ё|#TFi[YMb+EdIb[&yaIbSq'YfIb4QtؒؒEl#M-*#ϒŖ8˩V5.6$`lRlUb4bQg$5ebj:IEEt%K<*z͉ΤYaaKI0sgZlURX+ DXdK Xdqh=QV~T 9T6&~C1椌3*cբH&p1Ag"qaCq?[TD8[8BLB5ՠ(jM#A񄌸JIUy'g~:TXTK2p•**BK2', Kr()McK2Gďme3qeY K2sxh#e,-əm-Ȓm9(0C0ЉytN=(éC9eMg>-fv@̓VPb8;(å *̜էa؉dy1,t_ZvF E 98 xM24.]H43=O,$`lHe-]yU.PD ^jK粬.{LVt.*Ir$ k+PFv Hˡ&])Ő(x[VAeQdv B4*y$v"EpY.b j A? Y#͗`b!$jr]ͮ- sV^^P\2"JT0 4ơԣ7*傋r1%('sUx 7&h L,,)0|WP1MA p՜ ܏Nl+F;\Bdb`wr̤8;s|?=s?V`N;f&<`5VE([s?@Jd,#a(þ"1*fm3>–GDw"cXCI #'T3E@Й&aizQ&X@х@jlm7WZ-Z7m~v;[{ \Q< ";uVGnVW]= an]FE-@q9*D\|Vc{tB)* m܍VqsGO$B)WukUlQn:W۝2ɢ,bgI,7|w`#Zx?  1-X6}1MR,A12>.Y@Y xؔޔ@8#ęX~p/pĹ^xD9QC'' HZ}s?\O}-) ܳre<t- ݹut~tB'\H8y.y΃|Pz@i1ͽ:G)l$:pCk6o0uK =d9GPtQ˾8!5$%gLUի,UM\ Vx:%ݫ5uh '>Y( E(tŊ\75U «HC5``F$=>o0 .ap–†rA OMV/#˂烔iLTk,80O[,E?G({(&bĒ3t MNR0-&ૃLxVJ'QZJQji~F&[F4 )jgJXظ 0}S:/8h{ҴE;sT\Vr)}Hp%)vgL[BcA!Ion7]gp1{ik3?N -؇d3lj1)=~:>9{~ p=lF|T xJ]$.@/93=g=d?YGK"CxuA}Rk,;Nj+9ZG~ T7`DœqOcV$ (Z.?Iw[˹.y"Yjs/яE ރȔ?@ώC$.`6 A;|̇X#hA8XC>tHC5ai جCib:EݐΤ8gbUiE:8>(#SAcp٩yW1ɻq} JYF#p2WૺYҍe?L6ؖ|q ["y@uU 9JªZRBӏEQ#U!9ib%!K #cpcqHry[۵$emcF崏wڑ|QOtғHVbD~lnTEK~l[p:eFZ0+{.2fn3g4l,XD͞v*|tMیFﺇW庀.Ӛ4<ⱁ3Wgq|R8ِ ʽsG "Lѳ3=K0#a67@ umD#Dg':#:":mh^T7[ d/"; mF jq4 =-龵IbQzvSGŤH|tkBFy8F<CQNM}/g7_s)n{UȭІ?LK;'brT?"v+%JL-NهބhCpz~Wx:3smγ>6z,93gy|%yr,?>dQBɪYcKKTa\98ߎb)\.# i=bWhb<}}0/πelDs,;Mq0LEhTmܡ1NS> "?(/aAn`d V41=˄ɣ0%y?)X:'x@52>&gt_HH]L*AhHNH2́F֦AtP5T "(+mO5A&/WPEca^` 3H?'31d^G6])]C/ p%m_xM[95_5h {`-;-&0E ȲEVȀ_e4B␶h^vu4VcLkHlJ/FhBF; Ys^ؤ;CxH̄jalΤE~\'bH}ͳ;P݋o4R'S |}&c.ނ3ǖOlxi~D16a:#ѼeB1y%*12I&ߙ(F 4l\2FXU-\kEl##لZL`d!xАE(8ۉa/A#HHi?)Dz$k~/2S+LcBײɲ [ֱ*{$:9w+Ҭ,rdܽyȢ'KUGi؟̜bFqI.gPn֔#'KtR/LxtGR&E0o&WA L*=FJdsjzoo:kdky_9VL5t-dԛ[{ڪyS11oHHfT Gjak,hYT(q&mަ9ӑ|O~hֈUXՇYbOZ i,Wfa`[ 3]o @ ^X,f}y1c,jnT]CՈ=l8393B *l]vyF2'Ō}lGqR,%qӐuG缘~0m Sb ;i_~![{~!>MK|Mo0PtZ>C6cxR͙D 5A4`π% r7mܰ졧пMʿ{5L(IHgpɃ1)iF=;utRNY>:z?M+4Gciվ0$-llrʙ1w)lm|S_:M=[ [ VЂ0^1*]i6]ۻ6ZP['/NȻb!K;{9 v̶OV&*_T^B˼!ޫT_!/Ŗy˗t\C1Ïi\NN@`1  8!Q-\X&̿#HnuʵJcUrh%$3]`+U~`~0F,~^ȈLTڰs}lM0O"On7=^jP^SLoI<`A最Un=0sPIjpp#jQ_\u9^sMlX.4D" YT<lr eh\~zA-2j)_4_81d X1*zntԂo(~{eWPvs $t3\5&g#gZpbx3>W8{Ш(woQ_2sʰ!<}Ƅ<UvN$>vGC5Q, Ƽ\ w -fЯȚ=;##ܨg_G i=Y39lbpbul _ctpyk%Vp*))J]n9)D%"B}|=ypUֆߓuP5X#鮀nM߾tv~\6~Z^7SWA#MǛ'Boʖv[yEi[3T3q+@iK^H[B"0,>guՔ! |6!sfh9rԎr-Gf/kv;XH_Eg},X"Suj67^^o'Itt>>$6{kzq/0,|p?6D?FZS_F;8ɶ1hxp}Ć!Ta¸Mae8 u>syaP/=#VUzXo5vs'mہLj&U8B"3jqa ;|KaA+{oU|C=a6x68E> oh{CpA}:Ȇ?P1cQٔ\d $^S@e큍2ՙٰt-uTW;qc0\bAbvDi'xa9B$_.D}p;-чC}4@o1P mX pJqGčjHN;I% pKt/"`{Ht@$GH#Y~d1=ҡ[ Iib<( Ӆ/^+?3c< PKcS{b+]o`R1PYJuH;1FPX5Nqg{`zRĻ.EHzD77]72!?s Z4ڃu˪cU6OC-quZ|gQ12G|SI$?dZ32k\1X"D`,$ o}9Fn}^KEҸf%F?',OxS{ɴr=ӷ)h_9ct4E!hig5gxTVE#I\BzKRqQ/LO}Ч{Nӳq4GXrsÏ 7w<P65>}%OQ`/V)>֢5;c!”qq聐Mغ5wQVs`47d=0+\~ڟv[8A8'7tfoˍ 2зy~cqCmemF"BǍ8eĒF{*:;.C:<Mw0AyЃ.4U::G;cϦgU^IA;t=?$@W0ӭ*J||[(`{bҴج'#a=j<b|dI" WxfssYN=0y7g_qUC=E;5y=ATSKΦXV1c-c[WWuzg5 '_`p}Oht9 _B|q(N- oDǩMN=޻O/uHqȨ{j-59#ZK '@D 84,OT {WB>e<3*M3il5VT tAla| KK%8F;n9͆/(jdQE8/1]XL,<W:":Lre>ҧahv;6/ qX>R"<}. ]]7ޅ\reg\N<:X, {3sxo iNf|~N[B-p3DR+rF ,یpjWuEĩ >z\Lo73Gk<'n HAծ|YJH4dLߞ4Ϩ(sGN g9/JMރoi`!_h0Rg= tF?+p3? T/ Iҧ RX2f"dFd^]='_~O1_Qo8[Ӟ>z]7Z֣3/쳅n YWShv+h䥹C4E]R\(I>o; >58{W=z 8 p{ܽ"|?J6+[^Թ1Y3D!6,L;aTr&E9OQ8ݞo cm@p F;ΔAZ )iP1鄞7 KGW̝{QD9$,΅s៥sй~u.Z::G,!Ec*\\8Pqq(czp<ӃӃ魮Lt`z1?xy>+zLL/o_Tx\Nד` WKT{Ԕrk}paa9jE&4N݂g=Sxdzc]gl -=N'#z_o}9g{a{lk  !~~YS2h8M~Z}]ܟ~q\j*$V@T 5t>@ͷ(D=WStqMt[T_/h8B5%€s ȔvIwsZ'J9  L%v Xw{?g,5NG=ˮ bEF^")CL܀jُȷ\0s' j3vbu?ǵ<}jzs )W$lQA7 A򔒺a݂ VgSp P=q$: O5aZ;.U bzy xv2{cy!>)'M :'WL?{וBX(\| q?.Cڛ]A7R9Pr{wJB_#Oޟj;pE:HlS4?PHu;HxGvŋˮR"m- ׶ gE5B}p-#F,q ggA4߇;>֪yd a7xT(> Lo`O7+@UZ_m﮺Uݴs͆/j iI9|GKr]omAځGYހhhL< ȓwYTs'<'Ϋ^1x8;>~IէH~a8N'źZ`E5iug_2no: tGH2mKw9=|| MSo>` ׹N~ ΀W@ߎvݼfξo ?+|w~^pDG gkUpNǟG4zDy'ʆ𨸾~d׻b.]مvY(7)=rQ`G E*E;46 !k }FFByL) OTh%% N9N"]7M@!tO2q p[r4t6j8ù@z v4dZA&yhw*R{*EOOKH Kv,Nu7WQ~<v7z:l9Ԡ2R X6vFJCi@M]!)c[Jghb#HF;q!wNjE,Ҩ,wU=]JϨ&(۵Zs˵*XgvZdʪڔx̄;8r6SzB s,}em:&OZGm4"]Ra^k[՟)ҴaMr3XxR81zt2ahCjeN9tg4T9BP4f!?_3 7ZZ\oZcx8dAz@Y%Srܟ 73KҜnI6 ƋAy+HSzF.kxLʟv ydM}Uc!btOvV-FY׶ p%jkSt2S>7QNbGZy`)AiO˶y/Fæ0z^O-ǝNy~bYNΤS*Q1휗^Nǝj eE5){rU= 4„-eezW]t7LVzwT^SMqQq2Ҳ.n%N% \9|Z~⯮L亳n^.)!gME.[̋QiԼ'e)a_KE10'_+ vamPrN4}q:DUHRz3Es1YvV0t˥A'~jUJSN @zOVリ,]McC lޜrP31$BTջTŌxrÁU:(.jDw~wY.:.FE=]eFu{`t:08 NMjҌOFpl8GfQN9h~/xQfż-!fVUQ%~ԇTf:+&f6ݤ0 -y=wypLSޤ^.67sZsU]tf'nAFʋ 0AXLtaU @B!*TMV+csU3++X3 ۃbzYv0ԷIXͲz2wtѕ,{2#&rc=r{`琻j8_.Jk OXC lUݝ{\fw{I[ԯ/@&¡iH~6IrV=zHV٧ߗ@O'kT0.54C4><"̭d PrxaszRׁƚxڿ*W$X$Ι#&{SBX꫺NgJ ALa3,z̩t7ިLh>sж, ߫[p%3T5l;m? HV!3Cz|K\H4.씙=V6DIRhH2B5{ 6p8s.,v4w񞶬 A|nT#X-EyMȌ}'P5ISJ@E|(_|^ZVkP=`.%6suigiTؒmdKpA+&r*Q`ިEFcha'fT3`SD!tOFPZWOh S[Er2hP.q,<~BH$e5yns=kB7m`caqFO:Mw߳|L)Tm*S,i7_AF[q0)3_R}{Vrg?E~$-zXTN}3d_~d>I,B(HZqվE9H?9K"%lv׻#[4+X6s>vzY ,pM0T#`Zp-om޲)?Su5]ZjJxD#}1_5#]/;+Y$<簨܏ O,]5q)GN,`Rt&A%uCk;nrhꙭ#FmmQUOvSM-@k(K[-Eg -@#}+-S1Ӵ@{"Sw¦suqcfDA=SM(v:flD|E%`4'j ҶX̫]/ |V\kp r{0C'u$5 AӼΪ{zUV5Tq n;Ay.ts5;ɖz{s\+w_@A-a1jv)ZaFbI-{,rQTȐ=E=Cщ :$)#Puwp짪]ؼTt3oordJ*P:hJqժ)'uY/]!:!b-[eN,}S}goA?LZ'=zD6rML f .4] cx="8{N5k&dh1@',$})WJ`YD28@m}bSboh ̣w8tcCx8tDIÜp 9ߕ6sxٰfne%?y.x(ue _ ;Z8[~: קU9GC rp2Td_Nڻ.fThl'9]B<5t xM5?'A~8Q \S STN޼V-^&֧%1W!U$@H,^[B]eM DP0Qj'Tbq Xq< vX=@h̠ўewIX?!lk<,e^s7 ;v`ʪ3;<z2a?IȪqJUsM C[7떴O S]FRQBd`2ֱȍ~q $ò] vhN˒Iq^N J.&zzU7Wo }/5NʛZ8IB]-%6߹*`K7J;6ѓ9IxE@IS?w dԁ90`U[K uӞG&WɯyJ‡< ^mJYYkC[K=bGPIkb*Vh(!ș#Iv̝4d=\(;zxPEzĆP7AL]`VX->囒ޮ/a`-^ 0rZI i^>mڪEaڍPW<끶j97D~,5]j^OqUlu'7L;Diy]Obt M' =O ZpR"82F,ܤ3xn2-폲*|De<Nl[WDk_ףbww>:=ZM4.gcwɠE24EGg / p&g:jl/ H8tZ{ ]mJ|vq5ڕáO|b_N_CG.rG c*Ot;q}}\Y+e}^#=也&".˾M߻Ƒ'r }K~^j>¬`nmqGZ'5enjmy{K-Wv iLn+Bͨ<) i7\m8wc ]~%[Dl4J\͈j8zj\:S}5*۽"@5ʼn9knA+b|EC>AFUvUh$z|n1-*xe %*jF_M6GJ~< p=4$#/+שf_?` J͐H;z" `uCϞ;BW{_(bD%l 2XNӷ;U,a<&['sUldPQgyAD;]C6*4De[J{ufs'Gz*4TAwG8jjoYo\4W'7?W؆g&O(8d^uGۣH!&cL7x^!\o3yߊl Y~lB$<,O20cLp,y|AvI!yAG.sdP{D\.$ohl67/mup}?\jL, m7+*/?8ئNc@"G/&kDq-f֊N>o0qew9S}mpUK`V\M+ckOG3AS:wc=1}oU߳,V 8W4 kwC$nim7(# C5E6 0ciYG P_Bl&F @)M&É=`u2׬.Ww J }ie}gWnVXq틺,DO[~C}VӼ}mˈmQ<2YUT8Lt:FYv푶!<.ðbSCΖ|t.~l0}UBu Ar{0pY\}K Jp>gOݶhRۻݟNG0C=Bpگѩ+v<'2ώbEU;Vz3&_{ y^ochfx쐦Az*Ux$bbPVu [?'}V݂4d2k)'8`o왉s"E2y6EjkV ?yxˣ]qkww?~ͬ@%q>gٌ]%3'ʴsa~){?4~pib>X+fw;b^L «ٴ>\Ƙ*1 a=3bN'aV5Lg޶y7E1W/Bqx2 r g>r:AlrȎh?9i{FaI}M(Xt8u̐,2NLiB[ݛ$~j78MZ{.LnQs@;]D?1ݮbl8?<` 2 'ޓ$\$[6Tڿ'#Rwm~_sSUl 槀tH_s h|8r(S‡%<-4.M7HД$ OD4*J>:174|*Gw!:$]0KkAEJJRt%54w<ź{?xOAL < ;i wv &ґk;KNv.ezDRSN/KtVv+p ddfW+Fdzu7P;ȧ/uďju{|L&r×2^k}kxAq"U>ҝ9tbFcGm.FN]<$G%֟f vzS`ة׸/aLm!DGl9-)cg-`g}jY'oK+khVZaM:@c⹇^SE\O{٧'Lc{9||$,?'A= 'v{϶{+U|k킌?؀?9Y`{p`M)}*/򵰭bEz<@-c w4gsE%G$u \㣜ܽY_ E,i*#D ^RdQ߫tC#y+1ar6)R;ʴa˝Rކ8I]Ƚ?Dsyr=w]h:%(2/.:5>J9j+@^R嚨δN&_hgʼ,sRh?tGNSɜ# b+ΗP_)E쎁O$v D)xa8 SȊ8<с;>=Rq>vw5JwX@Ów^ >{sH܄^A^d՟_}|{@^8|;sl7?8y"S«ã߿:d@Ĺy{T\П7n O/9\*7m%~@&~K̓}y5^bBmۋJ4LSۯO޲n֔vrar'Qylg]? 4s=޿>^\jNݑnxM_`{OY%}}L gpXp\qdh̲)/訑N699Q-i-Ոzթ릢هZTF|ƊX/?`拥^"\qG`a(Yf:f?W||,LaG< (!4rwj*,ODeM`"8^B?A w"U*m4W9}/}&N }9Pb1=[PyG3 w8n wEӱC&^/fo|PQrGahh[ݐ>sA֚M +VH)ۚadQ7z)p>E .}#(Xu;VDg[r*gaJgEe@'b4`)©'_ȚϠ7KGryhzo>0ٱ* M7xW%d"P!UW޶;I7Z  d|Mac&8ɯ;Ld?%bZωݐ3‘qڭKy2N͜gɢ^vfR,]8oq|YӣO-zhN(k\vsi^Գވ>ͥӠ6.w*QCC^[cDo:qoGi $9g)4dQ&u;WsȅƐ(Kr4jX$V~@*Λ.5> PFt^ѿۑM.5xjp& A5imP!` ü\6"tR# ݅]60{"Hu\6W  y]+1JhXw5b/_5:ylӽ^S4[I/IBk6&[$Y[%R`Zf6#X9ށH{QPQOLpCt$#<D##SDxڨGް@m\cA^HV4s1͠b2NRҁ{`iu̠QC-;hOrqtSS59orq h0r Ū`y MF90!,pq?6D;EURA\˾7M\XUfrc?ҏhNd/a(!UR ?xRT_0ww;+ћLh/|)$Lw*Ҁ, ·b*6Lw@+g;|V AF3aňC nDZq,xy^ă7Q޼`r)- \||w#i56nGbem{mj}N677<` Ǥuy#{'+M-fޣfzDC^+c60 T{i5(nq1Ard97xlAu{/J*SZv(nݺU]]ڄTQ#8CPƦ?#r`T"C/eXQTb R=8f1ˤ=#D}I`諏ߩMRzط2]$SIU_P[<|FbVO]6_@_RAq<ĜXR{ Uي#v ROg/⺞LJUY(骓YbXǘ 3U%o}un̊LDIVTn]Z۱J5bn1[3rt2_RJbČT sN'[:< GbD{L-yS۱x| VT3 l=!bwgv1T,Gԡ~>~x猚d1ȧt23ǶcF"k=GRȼ2&ʋqmJ;[13JEf;Y;'kcaMg{xsT# Vȸ+o_Ha&myX&e%66\R ʓȮGA~gҝÅKaLy,FbƒJV[CXTVWl[=:)KIQiߥ?O#,!7:@-lG%,byfϭtaߊ_Z:>>Q?؞[[;Hz31tˆ添Fw =bSqnv?5^hmLg Kq̀`u5]4 ln]G|Gh mOA8n9ڟy}W qttXof<)ճҶoS1B'ޚh "spkNzcztaiu:p>m?L?0=%m+@nVKН$@GܲKb-Ђ=}MՍ@.Yg՝B+ar$mΉX_l^8Z?5U7I/5q7,k V۸{AS9仟yEY:lItIqo.By< x'~3kDL*^dQ̜n7Qf~++ePo#P(<(DdD}yh@dwlq{TSa݆@xf~ؼSs*zB/WKyH{R[1Ζ PbyK-?L")^F&_߾y!iI@2"=w/ IU/$nb@!lh|=]w_󖍣=Yhʁs~hX;` c#,=4 .[Jxh#JĬHqY_c=zkwDmfV/aފ^wn{T[l6LȰcv.Pxh[)6L,%wgY#|u 6'Urt cwYaZW>w<&cŮg-04ͼ= 3 ?Q0Dzqo hB~fB^J6ZX]&Rކ,7^JAY;X"/ʎ@[>P$8NYBkuVяY>r|`dQGq1ijY(J_3UqS(ϡzAPUUc(+\RXkw؏󻇘%{(Cy*k6r,K6|Ƀ];{f9zC ~CY2Ug,y.*KxEraLyHCUl8mA4Uwg'h͍}y <pP;6$8\b ޖn^p{<9ҳ$uK`L S HꪞTOt^1=|:ec?\3?.P,wӉ~`\2YHQW8wJWE0UGL?x-9{t`߯8ʯk+Sj(N hiV~~Ïb>_ E/z~?mU-,bQH%z0|Tǯ \ a:I*uq-0ݍ93{:RsFRb:8=+fZ;N]epM1SZ̈́kVäp,Qձ֖1 15յyv]NT_9J`@(؃zr]4s'2Y_9P vyEM{.|Ad)N hWDzzQ]"?u\)JP:RᓰFES&؃O? +!tzsUe"I"CK(?Aew@u|(/is~B391rGjV91 $2DO[Ҳ0>>&Yc"hĜb3Xn,+a0%br?~ c:quRggE}B7y`2)Y u*:z5M35>qu#6Szl?ҽ9&R_;x(Wqm?;`vhtl E& ns>ߧFfl&D~JveTVA-6Ym>Mt.(n!z>or5|Tբ_bh-Xb|촸.[ 3W0#'b1yS=@Xk9Vi㘩#O);Ço{=}PGR'U=mc-n41^%gݫlX>٤͓i=-z[8\a7[z @ߤJkJz]jVw*%WuCYi%/jdIlc&0t*% AӨrr}T c 4 A.Ja@"0ar^\# Pf>|' UA'g4rTۼ? U5Gv:cCP3~0%Ÿ-)1sI ?mO&%8h}\?T)] k"HhbrUoӲDIW?_`Srb՝|Am#U2ApH"I#FNѰq1F2jgnrX;;_|}W/w֐i7j ٧r>v-V~d9)oy2ky|Ně~堣gs>GA/knH|ln.]ڮp!$܇c3դZ܂'߻RܮOFO]bɟn-z΂*Z0;"{Tfr^\^ ֈ T C]Lnؘ{h3"W FnV!IZ(oE>1 /TdL˹q=F9u"\L0Zz!>PmZ_UrAq]ۥFp@g]2ܓP/͢>۲Ήx hM5i蔋xpn@U`FrY8(7Ch Umz:"߫#=ܭǴx,3Q #FB$VT7a@vx+)@TG`yiDt&k.L/Yu_.W$|'[ z+A2p Ѱt(_b,M]֜I$F aܺ1y6Ɖr4l${4Ջ򥽍0ŧ҇Ȳq0\>{Vx[_| X2yΫw7nFjxcܘhd-V]#9S8zKQZ8h0#Ηެ8"H{c1ʅ~Ё ..8n$rcWfk8ڤd%Ǡ2$ h$ \ dE.إCBy[V];] 6 "^{OIV~׏~U_n{a*:ğIoX6u΄ZPFlK껸2ѲY,?w;aJfc'PTQ} |Z,L:6߱\ۦMҽ3Ҷ~('V1=˶R`Rn~-o/ªc:^ڠ H'8b#ȽVeJjն.G&kLءDks?'\Q k,-q 1͋^!/Oe=0)oX[FwŜHCnB)g,zаuEįFug~b꼾 Z[ !O 2U wQ伦2{Ktl}_ݔ:1ƠhՕt]\葿5hG!/gbTR$g6|vm۠F]']`lSZ7y=DJɧ@YtK@/`rƮz\k;H?j#_7rhu5Iަ%$MBn'ȉ<qgֳ5uSI.<zyeXk O[XIҾ!r'?~8B~N5c}Y|F.gT-; .g;{uIAd&~Fr[)ӍorX#/+{ bg ۓ⼜xnbbD\.xRίUʹp(דK We'w@595`$*EгPQst K:P^̺[֋RYrYrY]爯H=.[Vq)?uM,zR&ާ+rZU& b/U9#^ -d}=UG#.3./ümL{xmΖ |pHakŔ*c?L1iZkʦå[x[ARXt1õ@o֘ DAI$q)G#'W;m)Àj<-׶ gNtQUm)_zc=}U 3|>ä=A+[ēy Sbȷl;,y똹E},TlOO/̣;'vMxJx[)0Q!OjcW v1_W bZ,htAZ]Om1`\0xzQgI]8zmM F;`kRSoPcqwTbDBR0^_A|aKX 'lpuQMՙYFW$̛|E-qwOXq}͏IS"o({-V-ؚnCN WXk_a7Pz`F5k;źDnifkM8?f1*d=NfQr>Qм2'jvsS;~~J2ǓwoW+7h3z1w_]sz]f[`eQз VI-nћwxYc9Kn? V[O#)nk< T. +LynR X =C#-}w$VY hJ)a%+ kdyUPP%g t³q =(D8)fh$s/|]7rDO "ޙ6R̜K#j=7} D DWB4N\ {2m3 9l8ۈxÓO?Tk?P){<^ӭHc Ay84Y EM)Rwtġ΋ѧKz숹: +\}q F.( 3+D"ؙ D ,_o)L;.OvX5k1܀AIԙjkS&]7t [m_hk$Z3/X(Ʋ~lyX+ МҏƹW0%0lUN;Ƀ{0:ߝW*5.b}c*8K:z>F׀F ݣܠkg3D6ٍD[;.O,PxYP*PQGRwfLmP8nWXC0V)tfg$}_s"r%:aẫUF,zl@^bNR,dJ!l#9ӋZf+z C[JMI(Hot<Uqbbxwov:`Af&_pu"(TyʛE_ljJLfo/. j d,uԑDnvĻc&"VY@GUJ)Ǽ.͹ ^5ޠE{Z*읞,Z׶wl_H^(uRkQŲ.㘥5sntQDGZ|l59|xLZE:F0ʃ9!&ܳF5X`qET P[ Gn+lܣ&B"T11k`ž Ϥdd16CqVwl$Z93^HG~YVI$Eds9s1ctH`C(+eLJџPՋIa[DݵO<{L+QIlٱ{M }J<.|9S, j/VaL9{>yE2cj݉ha5;,s[>_V]`~aqkxWżc1>,PS+ŗ9#&ſ{1׾x,@_, ecK-يo:Pz vؗ4 iLo,\S= hU\ :CHxi;]i[_^R5pGTP~DbKPaqcfB{ 0Zw2*CQZF1ًrRJ F<,t1mv^Kq9[ :2h鉳  )ͮ@|Cؑ߂;Q(HyTW%ddvu&&-tS)w,"R-k6߈WE˶TR~Y pMrjkj֕NS\Q!:-ͣpR4fm3ﴍ͹Ib.1_lMD* )_Ox㣷~߄8(݆DV`vQOrF {gm<>Znź B[L{z<7egC]J2G\m7dZ'3~dkYޔHѦWr6/gyJfBsI;]ray\ .Df2PᅚLF"F<2͂(x vlQ:CKb +~Ap:Ll/r2֯w̌H70liBYQl>)淪C8kK6uA xdZ3l>(ā(stX@uxf`+0et'9uٸ^OH\MqӪpL(PNfP'=zsGɹ@.WʍtL>?pb?8x<|]'GǕz@n'x󢜀Nt}rh 4-.=tF>ܼH<E l9.zK@@6uLjimTOeS4bT%1)Rb9s=~ԃNUNXv`}hH(NMu>h{@Lwm4LZk+4DD!֔4$Dx(:5㇘͸г"11О+`R?X9fZM"D$]qgFZs NmA)]UuUgٴEem"qpEEfoD6u{R^,tk/ߠstOnmTe1i0S`(,&3Yf/~-}N͇7mz7mb\-ksqvLXW~f^RQ}~̀MI*!Ŕ^ћKtlCi̒1:z9w\}[TH<#DlD q8dɓTlU?<]]F/5]B8#JRŗeA'[l/M`/SMbL5fA 5% ||h ޓ7M K17xbAf.6l@kg֘6͹ MX3*V^5G/7eΦ˔Т5T7:SHlWӝlSa&;|ҧC~df7EO Iƈif:҈?A%b]b %$WQٚ\hN,RX :}T>Z߱eV_KI:Έ[{OHE-˚/Fmc7#:Q uPE6˹]a f=[=YQ\fsħ%ZZāUΏlz :$5]d K0-m:5L)8rLL+e!Á^Xns ZǙP(Oѡu\:Иt,Km28 66M0K|pQ5IDbVqDNZOlɠC>ؚtM;1=47,TqN֛zB #«-52Pf% YDŽN}a`&ZuS0}>s\k~Vx>Rѥ2P2;f7 nVޤ@^ ,q1]xɣٝ0JD+f9LL9=L9&h}(e_1m]*1ĻYҀa4&5I&15+beNtA0k|bpPO]tS/ #" %v$ (,E8xJR $ghOp-RekN _4?n&r:P>퀶[=I5(@@9fE|_y7r7B|/ LuǬ0GƱ{+D R\uPQ 9dG(3>ϝL :MՀVW ulS8[J5(tsSW٦oчD ez&ꢦmb,b?}dRjפdͲM*B?`p:#'Ms [;{80/^68.̿,>g쨈[,IB92z7R ^TQ}ԭvFL줠lbm߈> oNu0kS66viͰڛ}qMKm 9"8/b{od"=*6@jV :xdkĸ3W>~s.zfݯVrZ] [scЇ9윷B^1=u pڮyRM?%dQ_ 2Jɞ6@h29&ĴgmFlಇ#Y:wpime=d6Yд?Dzʂg_@,ZlUmFL!dh {ۣEaid C! zcsi%h!MS_:7@\[J/oaCX>cb0ŸD'D0ܞV~ܳ_r^+2 ;LriO=6H1z<(5;MDu!э]1+خDy܏5qeթI1/ב[봙u\:cb à0R;4<ك%rv?fu5n%z]-ޘMOLbvW58Gy+Q&<$&FW_3d o(^Ih l0nCׄYwovc>ۧ'c*GoEn83&{?W>K>GYū,]!T_P 6B7>Zl@|d|^+KpCP/s(3&ڮCX#Q]:%n3|y=)ws艼vcW!^@,K9,;=pc{9n{CVa2XCq8U{ z\S{{Z Q!OsׅExw‰B`fYkkg*wU/Urb 0E|DY kzKkuC?6zJ[_iV\k mfH9y/'y1y7O!9\,.x7tm֣Icɮ~PNb}TG#28T#:tJd(*sb5/HDC15Ҫ q`7Y iyjl+韦̊9Np*_]3bܜf@ݝם,'?$2%Ro'w?k),يE; )G4K .^_J+gVr!Vus~#X1I㈊S M*'ewuބՌ/( L*`Ȩo9|˅`Hl%;;fm`L{WK+dI0l[6FL#J /SmkQ:{|`=pMxU0F[yaO w;}}H~l̾3\P)b qkgHݼͼW׵7\A2s)k?*;zFЎv9X BװW >&-MGpU=ޓ~U\O}^Nf\U|^(d{1O)~HE=KtNRs74u$QP̺kIhq.-:_PEK5Q )s;dztK2X۳z-oI?%h6aX/WgSdI^mOB7/:ʢ붑 nvlNreLZ`-U*hOQMGy@g vq<*ٮ3&Y޺r*SikV?){nUh=LڌX:yO]QKA)]%VQ*)hCuΰ#:C:G1 kE,˱_9s"y98J(5S{X>Vc 'TaͨgM}s!HM{=welXصMq\ԡ7o5Ń]rrOѦJξu{M5w @z0,6o1>(ˋQĆe3ٗGmEVO?ÐgCe?S@ h ؾ5Akl :}BC5FU< cZETNwX0O@8!1;)PX-m$nE3orhwxv!OQVy *vl;[\s+Qh\9m/H5YrwƝTPhxj"j28t5o@cOܻ~r^cGyVѨ!v|bo#s17korO?b˙sKV2H_ G0|4 Me "%. FbvNFAU/p's}l(4(MU>]dMPn|Y7BWݗD[/PzDFQy& y)0Et 9­~vD,SJ};PA7T(j((9&Frd -2t0oC)""*aOj~KK]]NO6sLg>0حsQ.XT<&JL΋'C7+ѐQrm+bAJ$GoiGxM,ZDApC#hBR{Mק"/9#d^Ii ۂwV?lPI\PIri~_Hr9\j9gN|vX@H2s < ^>Y9L?t*ofBn,t?Eȅϐ (!_^DK2mOu 8f"P`NN9-anKf=ژ5jټ0`"|f#s4;<2D`N~#O4$%W$ 7`1o䠴LJn7.-&Y>eyX!&/F2-)?NJ'Θ72֦u%oɢ^gJs4XS4/)N,ZAfvwLj>ĆKZF oe!cɌ;w$8($}P `A mN yU gZÝpY䠌[Wӽ{W}Up!tgM־5Ppc8fk@zVA e`!,sGiڧBa >&a0܆تԱZí#pec:ZڭbiSNi$X6]-I\#\FP9 mx fVIHUٱ>ܭ ȐkBcbKqtNT'HQݦJfuC6T/\V9ݫܸ-gÙj1Etj ic^__v%:Xe 2 W3qYwb&Φd]_\ݠ}OqEmM9MÐ}[o8\Ipm+n[lA`ms#$'v7ÇDJ)hDgoHrQ,OAp,эH7/ rvh0 w FxdYXb!G19Xβ'<'Y I!SFV9*M,?~$yѲL@/YE:S%7b5q/o 2YH "f*> u?metUw~םa}xxsgHe 1"||'9Trh0Z'ёxrbD3'wh1%&T} )@H )hu=a-KC`3S-}O'VS4ImɒծfؿK41FNpRMH*01Lz;y(](m:`݈puey \SǨoEy'Qden*9\s|CsF6h,䫋0H!t~ BZDUǝȨ>Cq# D1y1,4gpgdKtà'(tDxO ,9jџX6esf˞/z8fJڠ{zbtty! rfmYQ& apa˒ˮkܥcb0!b>K6XOiH#ԝ@5L1y ґ^v1 eA4Ő3Ã=JFe*m#NI@(Zsdq !Vi~|9)/9B/blVM=\3X;X\B&Uޙt_ eqӜJr^ysl6\ Ėj:f8MάL h8M' zϐa9mF.[!1z2F4L=]śo5k ;&v:jrh`mn[HJNOsYǫN! $JE;Ϣrqה Xɩ(:l0*[J * DU2V+s#+O4,E؂EQYT$Èh«?m H8W4kBݯ94{єKcf'g kΊ{jzIȄ64c8x{γ_2>Y֫Jηo]x\*OȈ22vŸFܜd)9Jf>eš&hQîHG 3 ȩƘR*QxS+J6kBX6nvڠI ƘNY"UA̖ا> ՘|֝ͻJ{KGbM~eDV$wY>n;/iBXkf5Qk:BM*c#fq WՒ*DR'9i@`Lx2IDN.Og2ja2Zw{&:hpXnjF& (9׸hc+9:%KGg,Tثe45>3lե(nxo1|4.t+%q8:{նRu:u4T5RM1Ux)/6ywʇ UGٛOz1KŠx{dMɟvOβg jgEI`CɞeWIYORt9Vl{e".a[b;[J-]Pu JCLsc!?[9w)-@I6B_˟ŭ2N %7gꙜ]]&Do~>wz TCm 2tU|{ 3#4ׁZ¬ )?eYUnhKօQ|DCLn MKc17΍QTң}soOiqm~fB~?RԄdo_Z;UMӮ Zmel"$Y1:FV z)jVkO!;@K!"Ѯ&3`tSWm72 3p@M4:6ן%bڴ:ACs)?tɱk [i@תR>%KkrǢ҇!%C+LHlT/2kݤWu }=T>5޹Z |Eypq?494 ڐSּcra=X:~J5t5 KZ&:N5Q ]ZuXP9=OD\LqCh\%XBwZ~-ˀר9i fW *K8;It0YY 6K5IGJPQ7ڨ"O]e_+}N-iNJ;k|whe3uJm-kyi{O k:"= ?ysoCyP >uEpTb;.7qCBĝlߏl 膉Su6fUbXNj.;{ۨeWJHr'iyG<$aA?։PcJ.ŋNrqgԶyA/~e:Z 'Ӟ/œM廏n&lRC!:cZJvw<|) sTSְP=ʩBU wnKVo3FʿlP!GI I'"~IKrOm[~6i(P;z_ѫǔyC;rCs?dK*5PzHq#~/{pQ@ws4}/qK400S lHݭf;Y:1Aݜը{_D3wBSj0[ta]L'ϋIv{ikɌ8`s] qK\wrecAIr E"wkS_ _ T;= zF_<5=3 nQxzBxS;j|wFͳ b[3$8yvxfcU$\{4|yF۴- [PZŖߍ:n+CF[)ʏƫTn23$B| u3zИ߹, $ RnwA,H/tnIKkA覮 ? yk~|N@|vK!e 2*{bp$b;ӗݺ *+ܓ4HvIRrʰo\ wU<㰔ydxG`\ QQzB9KE r$.?H kfxE{ |` m`\oX|ƙ5Ľ W/|\,+ PV3zmM[j6PΉCIp5 Ffʾ7k% :~جJ `,hB|TIJ|9%㶓;[ײa^]aE6u=Eڿ6"}$~-Vާ0I'M@Kl9!;Bz!RXe$|< \ pp8z,o nE6+%#ˣ8wFM47`~,/ lzëSPrY@;gdyA6L1M"1ɽ :/@oqfkB\v9;JN4Ir'}o{g$CaFh)!̓H0#G+e9al8*w^D"-%cMR;4qa {W^vn6l/w/\.1K׈UݟEx޶I 72S9O(;EJwfkP=e64wWR/Ju[Ћ[ 2[]QM?IaxojMtb6=~P}H+h1P0`U!$LUҏ[MI8) YǼ;WS4sƸa,1 L ڙ!49hD![Ɓ-Q吣$uhh!Se=Hĕ-md"u$3Yͣ' JChڢ+>4J̍* Y *F?P##2'bEG^\݌f6}7tP8n|y6x)==>p9tZ;^56ebnNkp1Ͱ2d1wa쬽xdp<0@0ƒ Mz㰱17}'YuYìo=E[ iݹMY\>;X'Wr!5:G%Ro -c_ ϓ {$9[ ܻ W$it# ~{ӞGs%Xq~hB/J-9|a@5q%]${`LJ5A+jNN$1@2]e-gC%$*1`-L䞿_1"T.N@ R1ÿ k|zmM^dS?VVAf?(KPvKY acK'"4g,sǥĚ䢏R+1{EPWfEƙ~UݒN+DGF _I 粨7/_ei^o)4q~*G4Zjޔxt vq#lKWX/| b(@ކǔXZX4pʐ +5$o^C lr6$pˊǷX#B;reΑfYNU埝E*߶"} PF]_lW2T+δlyAʵq|+;;2'2!/ /^6iՌnIpL uqFuw3Dv_h""LKp|,xB6~WRl~x^]%n^班x+y8F>U/Z,hPG/A!\gExT0Șc)kAF Kl>+d|\k~Gwod5MXmXEe *r+uD:¡U]q͞.-40T"|"ʋ$ϓKKQEZ,\G.yj% ?C0&՝HDc7xtl WqQ%ޒb/A`= <\xdDkDw4r)l{9~`r'u~^" g ,րw=/:Î VYvKD+Hi>OKC>)tD~JyW@52RylpD8F{yKD[ȇ5@^KH+k)zLAA v/!(g.smzeY(a] ԕyY.!"Y*j0Ď0y&Eti-xE֫Yq+i} ~ҠYlc ϲ~&2 ~_^^,[Tj]NmJcXғAH-~WD&H7է@e z\APQz=fޢ1tE {"7?H s ]:K6Zp;O{xanx/Iw>Olz:7ήތ.旣3ex|rDg-YmT$O/Ҭ)ˊ}'dDQ)dUWv%L٬I^t uZVgWjb?n@#`uq?bpw/\oO-`mQ2 $SCkC 0v5N#W^?+ԆPS|(g%^˽>%nz_jR 4r\3Fޱ]XݏAE?훏ځ0HyUp?V|3yn uyv㌜ N^4|wbؕU9a_ Q&3%tNs%1 Wлߊ,[dޘԬ. qOV;k)r,%iޞ'(<| 7:5Fܡx Ja~UDu*ނE8fKCw&ex '1Dz^o'dt>ÛU9[q:_rrNͰ6*nP`)W|ɧ3]gZ\NTKV+Q=D4"I|g@N V US-$x'tN]WT,F^/n(ZPd.90&8au(9f,%>pΓztSKgߙxlYZ)Z1DI< i/u{J/^Gn0k02hɸZኽFwcT )<rAN-V89t5e  E; LI33tL*#ܛ(y`7PxpurrFT!CK]nFk ad`Oɏre] Ǘ ̼TYuMHx;Uf)@_s~ʳó ;` #/Z?96-LV(% JҫWn&{K~,37Wv_8iHs|Bnr6_^}LQ?aʴHOM7v`4/Re.6µ6=xrEwiZ"xP"|$Ix5ur̡An1‘E+|Խ|i!Ia<]/NF*r8 }JqV 曍ZC8˘m.ԉxrY2#F+K=uwg+;+Hx}i4AWqa:0HgDDYJΪRZwofa Iw9ek"btm~lh3(0rhv.|rv7<|Oۀ|k4/_==ocܣX\7R`)$j/ $ުWw6~N%|OJguަ%n~֪;1u1;d'w嫮}{rWɵ&󫷖|Etz`e ~'ߖ ?=ύ0% U8qk9q/7$j={փ7߃ڃ nDVo,~Tez&Ź-p* F*z-oΈ)/q1Kvre42MJ䮂MqĬF"sYz1FLLo%l] ~*s9ry+ aO.A{CEޣ9{-`]ʛΨ*L=66R5̍lNF~ Ɉ~A@z?ЕG_4Y{ // chrome-extension://ahjaciijnoiaklcomgnblndopackapon "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNyyvaNmqNZsjBwes4YNlrsy64asdP710pdMUM27jtvOe2YkXUdvglcC6r2ihlvPg16mjYK+ZmvxchcEu497KUPqBq34jXILabiUuXLrQJlvl3A7QMLatuZlijSx1qXL/5w5/ggF2Tblo9SHSVtlVyhwyyGkT9ckga5erBUbbwkQIDAQAB", "name": "Identity API Scope Approval UI", "version": "1.1", "manifest_version": 2, "description": "Displays scope approval dialog boxes for the Identity API", "permissions": [ "chrome://theme/", "identityPrivate", "resourcesPrivate", "webview" ], "app": { "background": { "scripts": [ "background.js" ] }, "content_security_policy": "default-src 'none'; script-src 'self' blob: filesystem:; style-src 'self' blob: filesystem:; img-src chrome://theme; object-src 'self' blob: filesystem:" }, "display_in_launcher": false, "display_in_new_tab_page": false } $i18n{title}
/* 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, #contents, #signin-frame { height: 100%; margin: 0; overflow: hidden; padding: 0; width: 100%; } #signin-frame, #spinner-container { background-color: #f5f5f5; bottom: 0; left: 0; position: absolute; right: 0; top: 0; } #spinner-container { -webkit-box-align: center; -webkit-box-pack: center; display: -webkit-box; } #contents:not(.loading) #spinner-container { display: none; } #navigation-button { color: white; position: absolute; top: 0; visibility: hidden; } #navigation-button.enabled { 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. /** * @fileoverview Inline login UI. */ cr.define('inline.login', function() { 'use strict'; /** * The auth extension host instance. * @type {cr.login.GaiaAuthHost} */ var authExtHost; /** * Whether the auth ready event has been fired, for testing purpose. */ var authReadyFired; /** * Whether the login UI is loaded for signing in primary account. */ var isLoginPrimaryAccount; function onResize(e) { chrome.send('switchToFullTab', [e.detail]); } function onAuthReady(e) { $('contents').classList.toggle('loading', false); authReadyFired = true; if (isLoginPrimaryAccount) chrome.send('metricsHandler:recordAction', ['Signin_SigninPage_Shown']); } function onDropLink(e) { // Navigate to the dropped link. window.location.href = e.detail; } function onNewWindow(e) { window.open(e.detail.targetUrl, '_blank'); e.detail.window.discard(); } function onAuthCompleted(e) { completeLogin(e.detail); } function completeLogin(credentials) { chrome.send('completeLogin', [credentials]); $('contents').classList.toggle('loading', true); } /** * Initialize the UI. */ function initialize() { $('navigation-button').addEventListener('click', navigationButtonClicked); authExtHost = new cr.login.GaiaAuthHost('signin-frame'); authExtHost.addEventListener('dropLink', onDropLink); authExtHost.addEventListener('ready', onAuthReady); authExtHost.addEventListener('newWindow', onNewWindow); authExtHost.addEventListener('resize', onResize); authExtHost.addEventListener('authCompleted', onAuthCompleted); chrome.send('initialize'); } /** * Loads auth extension. * @param {Object} data Parameters for auth extension. */ function loadAuthExtension(data) { // TODO(rogerta): in when using webview, the |completeLogin| argument // is ignored. See addEventListener() call above. authExtHost.load(data.authMode, data, completeLogin); $('contents') .classList.toggle( 'loading', data.authMode != cr.login.GaiaAuthHost.AuthMode.DESKTOP || data.constrained == '1'); isLoginPrimaryAccount = data.isLoginPrimaryAccount; } /** * Closes the inline login dialog. */ function closeDialog() { chrome.send('dialogClose', ['']); } /** * Invoked when failed to get oauth2 refresh token. */ function handleOAuth2TokenFailure() { // TODO(xiyuan): Show an error UI. authExtHost.reload(); $('contents').classList.toggle('loading', true); } /** * Returns the auth host instance, for testing purpose. */ function getAuthExtHost() { return authExtHost; } /** * Returns whether the auth UI is ready, for testing purpose. */ function isAuthReady() { return authReadyFired; } function showBackButton() { $('navigation-button').icon = isRTL() ? 'icons:arrow-forward' : 'icons:arrow-back'; $('navigation-button') .setAttribute( 'aria-label', loadTimeData.getString('accessibleBackButtonLabel')); } function showCloseButton() { $('navigation-button').icon = 'icons:close'; $('navigation-button').classList.add('enabled'); $('navigation-button') .setAttribute( 'aria-label', loadTimeData.getString('accessibleCloseButtonLabel')); } function navigationButtonClicked() { chrome.send('navigationButtonClicked'); } return { closeDialog: closeDialog, getAuthExtHost: getAuthExtHost, handleOAuth2TokenFailure: handleOAuth2TokenFailure, initialize: initialize, isAuthReady: isAuthReady, loadAuthExtension: loadAuthExtension, navigationButtonClicked: navigationButtonClicked, showBackButton: showBackButton, showCloseButton: showCloseButton }; }); document.addEventListener('DOMContentLoaded', inline.login.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. // // 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. // // 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 * Provides a HTML5 postMessage channel to the injected JS to talk back * to Authenticator. */ 'use strict'; // // 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(); }; var PostMessageChannel = (function() { /** * Allowed origins of the hosting page. * @type {Array} */ var ALLOWED_ORIGINS = ['chrome://oobe', 'chrome://chrome-signin']; /** @const */ var PORT_MESSAGE = 'post-message-port-message'; /** @const */ var CHANNEL_INIT_MESSAGE = 'post-message-channel-init'; /** @const */ var CHANNEL_CONNECT_MESSAGE = 'post-message-channel-connect'; /** * Whether the script runs in a top level window. */ function isTopLevel() { return window === window.top; } /** * A simple event target. */ function EventTarget() { this.listeners_ = []; } EventTarget.prototype = { /** * Add an event listener. */ addListener: function(listener) { this.listeners_.push(listener); }, /** * Dispatches a given event to all listeners. */ dispatch: function(e) { for (var i = 0; i < this.listeners_.length; ++i) { this.listeners_[i].call(undefined, e); } } }; /** * ChannelManager handles window message events by dispatching them to * PostMessagePorts or forwarding to other windows (up/down the hierarchy). * @constructor */ function ChannelManager() { /** * Window and origin to forward message up the hierarchy. For subframes, * they defaults to window.parent and any origin. For top level window, * this would be set to the hosting webview on CHANNEL_INIT_MESSAGE. */ this.upperWindow = isTopLevel() ? null : window.parent; this.upperOrigin = isTopLevel() ? '' : '*'; /** * Channle Id to port map. * @type {Object} */ this.channels_ = {}; /** * Deferred messages to be posted to |upperWindow|. * @type {Array} */ this.deferredUpperWindowMessages_ = []; /** * Ports that depend on upperWindow and need to be setup when its available. */ this.deferredUpperWindowPorts_ = []; /** * Whether the ChannelManager runs in daemon mode and accepts connections. */ this.isDaemon = false; /** * Fires when ChannelManager is in listening mode and a * CHANNEL_CONNECT_MESSAGE is received. */ this.onConnect = new EventTarget(); window.addEventListener('message', this.onMessage_.bind(this)); } ChannelManager.prototype = { /** * Gets a global unique id to use. * @return {number} */ createChannelId_: function() { return (new Date()).getTime(); }, /** * Posts data to upperWindow. Queue it if upperWindow is not available. */ postToUpperWindow: function(data) { if (this.upperWindow == null) { this.deferredUpperWindowMessages_.push(data); return; } this.upperWindow.postMessage(data, this.upperOrigin); }, /** * Creates a port and register it in |channels_|. * @param {number} channelId * @param {string} channelName * @param {DOMWindow=} opt_targetWindow * @param {string=} opt_targetOrigin */ createPort: function( channelId, channelName, opt_targetWindow, opt_targetOrigin) { var port = new PostMessagePort(channelId, channelName); if (opt_targetWindow) port.setTarget(opt_targetWindow, opt_targetOrigin); this.channels_[channelId] = port; return port; }, /* * Returns a message forward handler for the given proxy port. * @private */ getProxyPortForwardHandler_: function(proxyPort) { return function(msg) { proxyPort.postMessage(msg); }; }, /** * Creates a forwarding porxy port. * @param {number} channelId * @param {string} channelName * @param {!DOMWindow} targetWindow * @param {!string} targetOrigin */ createProxyPort: function( channelId, channelName, targetWindow, targetOrigin) { var port = this.createPort(channelId, channelName, targetWindow, targetOrigin); port.onMessage.addListener(this.getProxyPortForwardHandler_(port)); return port; }, /** * Creates a connecting port to the daemon and request connection. * @param {string} name * @return {PostMessagePort} */ connectToDaemon: function(name) { if (this.isDaemon) { console.error( 'Error: Connecting from the daemon page is not supported.'); return; } var port = this.createPort(this.createChannelId_(), name); if (this.upperWindow) { port.setTarget(this.upperWindow, this.upperOrigin); } else { this.deferredUpperWindowPorts_.push(port); } this.postToUpperWindow({ type: CHANNEL_CONNECT_MESSAGE, channelId: port.channelId, channelName: port.name }); return port; }, /** * Dispatches a 'message' event to port. * @private */ dispatchMessageToPort_: function(e) { var channelId = e.data.channelId; var port = this.channels_[channelId]; if (!port) { console.error('Error: Unable to dispatch message. Unknown channel.'); return; } port.handleWindowMessage(e); }, /** * Window 'message' handler. */ onMessage_: function(e) { if (typeof e.data != 'object' || !e.data.hasOwnProperty('type')) { return; } if (e.data.type === PORT_MESSAGE) { // Dispatch port message to ports if this is the daemon page or // the message is from upperWindow. In case of null upperWindow, // the message is assumed to be forwarded to upperWindow and queued. if (this.isDaemon || (this.upperWindow && e.source === this.upperWindow)) { this.dispatchMessageToPort_(e); } else { this.postToUpperWindow(e.data); } } else if (e.data.type === CHANNEL_CONNECT_MESSAGE) { var channelId = e.data.channelId; var channelName = e.data.channelName; if (this.isDaemon) { var port = this.createPort(channelId, channelName, e.source, e.origin); this.onConnect.dispatch(port); } else { this.createProxyPort(channelId, channelName, e.source, e.origin); this.postToUpperWindow(e.data); } } else if (e.data.type === CHANNEL_INIT_MESSAGE) { if (ALLOWED_ORIGINS.indexOf(e.origin) == -1) return; this.upperWindow = e.source; this.upperOrigin = e.origin; for (var i = 0; i < this.deferredUpperWindowMessages_.length; ++i) { this.upperWindow.postMessage( this.deferredUpperWindowMessages_[i], this.upperOrigin); } this.deferredUpperWindowMessages_ = []; for (var i = 0; i < this.deferredUpperWindowPorts_.length; ++i) { this.deferredUpperWindowPorts_[i].setTarget( this.upperWindow, this.upperOrigin); } this.deferredUpperWindowPorts_ = []; } } }; /** * Singleton instance of ChannelManager. * @type {ChannelManager} */ var channelManager = new ChannelManager(); /** * A HTML5 postMessage based port that provides the same port interface * as the messaging API port. * @param {number} channelId * @param {string} name */ function PostMessagePort(channelId, name) { this.channelId = channelId; this.name = name; this.targetWindow = null; this.targetOrigin = ''; this.deferredMessages_ = []; this.onMessage = new EventTarget(); } PostMessagePort.prototype = { /** * Sets the target window and origin. * @param {DOMWindow} targetWindow * @param {string} targetOrigin */ setTarget: function(targetWindow, targetOrigin) { this.targetWindow = targetWindow; this.targetOrigin = targetOrigin; for (var i = 0; i < this.deferredMessages_.length; ++i) { this.postMessage(this.deferredMessages_[i]); } this.deferredMessages_ = []; }, postMessage: function(msg) { if (!this.targetWindow) { this.deferredMessages_.push(msg); return; } this.targetWindow.postMessage( {type: PORT_MESSAGE, channelId: this.channelId, payload: msg}, this.targetOrigin); }, handleWindowMessage: function(e) { this.onMessage.dispatch(e.data.payload); } }; /** * A message channel based on PostMessagePort. * @extends {Channel} * @constructor */ function PostMessageChannel() { Channel.apply(this, arguments); } PostMessageChannel.prototype = { __proto__: Channel.prototype, /** @override */ connect: function(name) { this.port_ = channelManager.connectToDaemon(name); this.port_.onMessage.addListener(this.onMessage_.bind(this)); }, }; /** * Initialize webview content window for postMessage channel. * @param {DOMWindow} webViewContentWindow Content window of the webview. */ PostMessageChannel.init = function(webViewContentWindow) { webViewContentWindow.postMessage({type: CHANNEL_INIT_MESSAGE}, '*'); }; /** * Run in daemon mode and listen for incoming connections. Note that the * current implementation assumes the daemon runs in the hosting page * at the upper layer of the DOM tree. That is, all connect requests go * up the DOM tree instead of going into sub frames. * @param {function(PostMessagePort)} callback Invoked when a connection is * made. */ PostMessageChannel.runAsDaemon = function(callback) { channelManager.isDaemon = true; var onConnect = function(port) { callback(port); }; channelManager.onConnect.addListener(onConnect); }; return PostMessageChannel; })(); /** @override */ Channel.create = function() { return new PostMessageChannel(); }; // // Copyright 2017 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 WebviewEventManager which can register and keep track of listeners * on EventTargets and WebRequests, and unregister all listeners later. */ 'use strict'; /** * Creates a new WebviewEventManager. */ function WebviewEventManager() { this.unbindWebviewCleanupFunctions_ = []; } WebviewEventManager.prototype = { /** * Adds a EventListener to |eventTarget| and adds a clean-up function so we * can remove the listener in unbindFromWebview. * @param {Object} webview the object to add the listener to * @param {string} type the event type * @param {Function} listener the event listener * @private */ addEventListener: function(eventTarget, type, listener) { eventTarget.addEventListener(type, listener); this.unbindWebviewCleanupFunctions_.push( eventTarget.removeEventListener.bind(eventTarget, type, listener)); }, /** * Adds a listener to |webRequestEvent| and adds a clean-up function so we can * remove the listener in unbindFromWebview. * @param {Object} webRequestEvent the object to add the listener to * @param {string} type the event type * @param {Function} listener the event listener * @private */ addWebRequestEventListener: function( webRequestEvent, listener, filter, extraInfoSpec) { webRequestEvent.addListener(listener, filter, extraInfoSpec); this.unbindWebviewCleanupFunctions_.push( webRequestEvent.removeListener.bind(webRequestEvent, listener)); }, /** * Unbinds this Authenticator from the currently bound webview. * @private */ removeAllListeners: function() { for (var i = 0; i < this.unbindWebviewCleanupFunctions_.length; i++) this.unbindWebviewCleanupFunctions_[i](); this.unbindWebviewCleanupFunctions_ = []; } }; /** * Class factory. * @return {WebviewEventManager} */ WebviewEventManager.create = function() { return new WebviewEventManager(); }; /** * @fileoverview Saml support for webview based auth. */ cr.define('cr.login', function() { 'use strict'; /** * The lowest version of the credentials passing API supported. * @type {number} */ var MIN_API_VERSION_VERSION = 1; /** * The highest version of the credentials passing API supported. * @type {number} */ var MAX_API_VERSION_VERSION = 1; /** * The key types supported by the credentials passing API. * @type {Array} Array of strings. */ var API_KEY_TYPES = [ 'KEY_TYPE_PASSWORD_PLAIN', ]; /** @const */ var SAML_HEADER = 'google-accounts-saml'; /** @const */ var injectedScriptName = 'samlInjected'; /** * The script to inject into webview and its sub frames. * @type {string} */ var injectedJs = String.raw` // // 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. // // 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 * Provides a HTML5 postMessage channel to the injected JS to talk back * to Authenticator. */ 'use strict'; // // 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(); }; var PostMessageChannel = (function() { /** * Allowed origins of the hosting page. * @type {Array} */ var ALLOWED_ORIGINS = ['chrome://oobe', 'chrome://chrome-signin']; /** @const */ var PORT_MESSAGE = 'post-message-port-message'; /** @const */ var CHANNEL_INIT_MESSAGE = 'post-message-channel-init'; /** @const */ var CHANNEL_CONNECT_MESSAGE = 'post-message-channel-connect'; /** * Whether the script runs in a top level window. */ function isTopLevel() { return window === window.top; } /** * A simple event target. */ function EventTarget() { this.listeners_ = []; } EventTarget.prototype = { /** * Add an event listener. */ addListener: function(listener) { this.listeners_.push(listener); }, /** * Dispatches a given event to all listeners. */ dispatch: function(e) { for (var i = 0; i < this.listeners_.length; ++i) { this.listeners_[i].call(undefined, e); } } }; /** * ChannelManager handles window message events by dispatching them to * PostMessagePorts or forwarding to other windows (up/down the hierarchy). * @constructor */ function ChannelManager() { /** * Window and origin to forward message up the hierarchy. For subframes, * they defaults to window.parent and any origin. For top level window, * this would be set to the hosting webview on CHANNEL_INIT_MESSAGE. */ this.upperWindow = isTopLevel() ? null : window.parent; this.upperOrigin = isTopLevel() ? '' : '*'; /** * Channle Id to port map. * @type {Object} */ this.channels_ = {}; /** * Deferred messages to be posted to |upperWindow|. * @type {Array} */ this.deferredUpperWindowMessages_ = []; /** * Ports that depend on upperWindow and need to be setup when its available. */ this.deferredUpperWindowPorts_ = []; /** * Whether the ChannelManager runs in daemon mode and accepts connections. */ this.isDaemon = false; /** * Fires when ChannelManager is in listening mode and a * CHANNEL_CONNECT_MESSAGE is received. */ this.onConnect = new EventTarget(); window.addEventListener('message', this.onMessage_.bind(this)); } ChannelManager.prototype = { /** * Gets a global unique id to use. * @return {number} */ createChannelId_: function() { return (new Date()).getTime(); }, /** * Posts data to upperWindow. Queue it if upperWindow is not available. */ postToUpperWindow: function(data) { if (this.upperWindow == null) { this.deferredUpperWindowMessages_.push(data); return; } this.upperWindow.postMessage(data, this.upperOrigin); }, /** * Creates a port and register it in |channels_|. * @param {number} channelId * @param {string} channelName * @param {DOMWindow=} opt_targetWindow * @param {string=} opt_targetOrigin */ createPort: function( channelId, channelName, opt_targetWindow, opt_targetOrigin) { var port = new PostMessagePort(channelId, channelName); if (opt_targetWindow) port.setTarget(opt_targetWindow, opt_targetOrigin); this.channels_[channelId] = port; return port; }, /* * Returns a message forward handler for the given proxy port. * @private */ getProxyPortForwardHandler_: function(proxyPort) { return function(msg) { proxyPort.postMessage(msg); }; }, /** * Creates a forwarding porxy port. * @param {number} channelId * @param {string} channelName * @param {!DOMWindow} targetWindow * @param {!string} targetOrigin */ createProxyPort: function( channelId, channelName, targetWindow, targetOrigin) { var port = this.createPort(channelId, channelName, targetWindow, targetOrigin); port.onMessage.addListener(this.getProxyPortForwardHandler_(port)); return port; }, /** * Creates a connecting port to the daemon and request connection. * @param {string} name * @return {PostMessagePort} */ connectToDaemon: function(name) { if (this.isDaemon) { console.error( 'Error: Connecting from the daemon page is not supported.'); return; } var port = this.createPort(this.createChannelId_(), name); if (this.upperWindow) { port.setTarget(this.upperWindow, this.upperOrigin); } else { this.deferredUpperWindowPorts_.push(port); } this.postToUpperWindow({ type: CHANNEL_CONNECT_MESSAGE, channelId: port.channelId, channelName: port.name }); return port; }, /** * Dispatches a 'message' event to port. * @private */ dispatchMessageToPort_: function(e) { var channelId = e.data.channelId; var port = this.channels_[channelId]; if (!port) { console.error('Error: Unable to dispatch message. Unknown channel.'); return; } port.handleWindowMessage(e); }, /** * Window 'message' handler. */ onMessage_: function(e) { if (typeof e.data != 'object' || !e.data.hasOwnProperty('type')) { return; } if (e.data.type === PORT_MESSAGE) { // Dispatch port message to ports if this is the daemon page or // the message is from upperWindow. In case of null upperWindow, // the message is assumed to be forwarded to upperWindow and queued. if (this.isDaemon || (this.upperWindow && e.source === this.upperWindow)) { this.dispatchMessageToPort_(e); } else { this.postToUpperWindow(e.data); } } else if (e.data.type === CHANNEL_CONNECT_MESSAGE) { var channelId = e.data.channelId; var channelName = e.data.channelName; if (this.isDaemon) { var port = this.createPort(channelId, channelName, e.source, e.origin); this.onConnect.dispatch(port); } else { this.createProxyPort(channelId, channelName, e.source, e.origin); this.postToUpperWindow(e.data); } } else if (e.data.type === CHANNEL_INIT_MESSAGE) { if (ALLOWED_ORIGINS.indexOf(e.origin) == -1) return; this.upperWindow = e.source; this.upperOrigin = e.origin; for (var i = 0; i < this.deferredUpperWindowMessages_.length; ++i) { this.upperWindow.postMessage( this.deferredUpperWindowMessages_[i], this.upperOrigin); } this.deferredUpperWindowMessages_ = []; for (var i = 0; i < this.deferredUpperWindowPorts_.length; ++i) { this.deferredUpperWindowPorts_[i].setTarget( this.upperWindow, this.upperOrigin); } this.deferredUpperWindowPorts_ = []; } } }; /** * Singleton instance of ChannelManager. * @type {ChannelManager} */ var channelManager = new ChannelManager(); /** * A HTML5 postMessage based port that provides the same port interface * as the messaging API port. * @param {number} channelId * @param {string} name */ function PostMessagePort(channelId, name) { this.channelId = channelId; this.name = name; this.targetWindow = null; this.targetOrigin = ''; this.deferredMessages_ = []; this.onMessage = new EventTarget(); } PostMessagePort.prototype = { /** * Sets the target window and origin. * @param {DOMWindow} targetWindow * @param {string} targetOrigin */ setTarget: function(targetWindow, targetOrigin) { this.targetWindow = targetWindow; this.targetOrigin = targetOrigin; for (var i = 0; i < this.deferredMessages_.length; ++i) { this.postMessage(this.deferredMessages_[i]); } this.deferredMessages_ = []; }, postMessage: function(msg) { if (!this.targetWindow) { this.deferredMessages_.push(msg); return; } this.targetWindow.postMessage( {type: PORT_MESSAGE, channelId: this.channelId, payload: msg}, this.targetOrigin); }, handleWindowMessage: function(e) { this.onMessage.dispatch(e.data.payload); } }; /** * A message channel based on PostMessagePort. * @extends {Channel} * @constructor */ function PostMessageChannel() { Channel.apply(this, arguments); } PostMessageChannel.prototype = { __proto__: Channel.prototype, /** @override */ connect: function(name) { this.port_ = channelManager.connectToDaemon(name); this.port_.onMessage.addListener(this.onMessage_.bind(this)); }, }; /** * Initialize webview content window for postMessage channel. * @param {DOMWindow} webViewContentWindow Content window of the webview. */ PostMessageChannel.init = function(webViewContentWindow) { webViewContentWindow.postMessage({type: CHANNEL_INIT_MESSAGE}, '*'); }; /** * Run in daemon mode and listen for incoming connections. Note that the * current implementation assumes the daemon runs in the hosting page * at the upper layer of the DOM tree. That is, all connect requests go * up the DOM tree instead of going into sub frames. * @param {function(PostMessagePort)} callback Invoked when a connection is * made. */ PostMessageChannel.runAsDaemon = function(callback) { channelManager.isDaemon = true; var onConnect = function(port) { callback(port); }; channelManager.onConnect.addListener(onConnect); }; return PostMessageChannel; })(); /** @override */ Channel.create = function() { return new PostMessageChannel(); }; // // 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; var fieldId = passwordField.id || passwordField.name || ''; passwordField.addEventListener( 'input', this.onPasswordChanged_.bind(this, index, fieldId)); 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, fieldId) { 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, // password field index and id to construct a unique ID for the password // field. var passwordId = this.pageURL_.split('#')[0].split('?')[0] + '|' + index + '|' + fieldId; 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_|. * @param {string} fieldId The id or name of the password field or blank. */ onPasswordChanged_: function(index, fieldId) { this.maybeSendUpdatedPassword(index, fieldId); } }; 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); })(); `; /** * 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; } /** * A handler to provide saml support for the given webview that hosts the * auth IdP pages. * @extends {cr.EventTarget} * @param {webview} webview * @constructor */ function SamlHandler(webview) { /** * The webview that serves IdP pages. * @type {webview} */ this.webview_ = webview; /** * Whether a Saml IdP page is display in the webview. * @type {boolean} */ this.isSamlPage_ = false; /** * Pending Saml IdP page flag that is set when a SAML_HEADER is received * and is copied to |isSamlPage_| in loadcommit. * @type {boolean} */ this.pendingIsSamlPage_ = false; /** * The last aborted top level url. It is recorded in loadabort event and * used to skip injection into Chrome's error page in the following * loadcommit event. * @type {string} */ this.abortedTopLevelUrl_ = null; /** * The domain of the Saml IdP. * @type {string} */ this.authDomain = ''; /** * Scraped password stored in an id to password field value map. * @type {Object} * @private */ this.passwordStore_ = {}; /** * Whether Saml API is initialized. * @type {boolean} */ this.apiInitialized_ = false; /** * Saml API version to use. * @type {number} */ this.apiVersion_ = 0; /** * Saml API token received. * @type {string} */ this.apiToken_ = null; /** * Saml API password bytes. * @type {string} */ this.apiPasswordBytes_ = null; /* * Whether to abort the authentication flow and show an error messagen when * content served over an unencrypted connection is detected. * @type {boolean} */ this.blockInsecureContent = false; this.webviewEventManager_ = WebviewEventManager.create(); this.webviewEventManager_.addEventListener( this.webview_, 'contentload', this.onContentLoad_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'loadabort', this.onLoadAbort_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'loadcommit', this.onLoadCommit_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'permissionrequest', this.onPermissionRequest_.bind(this)); this.webviewEventManager_.addWebRequestEventListener( this.webview_.request.onBeforeRequest, this.onInsecureRequest.bind(this), {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, ['blocking']); this.webviewEventManager_.addWebRequestEventListener( this.webview_.request.onHeadersReceived, this.onHeadersReceived_.bind(this), {urls: [''], types: ['main_frame', 'xmlhttprequest']}, ['blocking', 'responseHeaders']); this.webview_.addContentScripts([{ name: injectedScriptName, matches: ['http://*/*', 'https://*/*'], js: {code: injectedJs}, all_frames: true, run_at: 'document_start' }]); PostMessageChannel.runAsDaemon(this.onConnected_.bind(this)); } SamlHandler.prototype = { __proto__: cr.EventTarget.prototype, /** * Whether Saml API is used during auth. * @return {boolean} */ get samlApiUsed() { return !!this.apiPasswordBytes_; }, /** * Returns the Saml API password bytes. * @return {string} */ get apiPasswordBytes() { return this.apiPasswordBytes_; }, /** * Returns the first scraped password if any, or an empty string otherwise. * @return {string} */ get firstScrapedPassword() { var scraped = this.getConsolidatedScrapedPasswords_(); return scraped.length ? scraped[0] : ''; }, /** * Returns the number of scraped passwords. * @return {number} */ get scrapedPasswordCount() { return this.getConsolidatedScrapedPasswords_().length; }, /** * Gets the de-duped scraped passwords. * @return {Array} * @private */ getConsolidatedScrapedPasswords_: function() { var passwords = {}; for (var property in this.passwordStore_) { passwords[this.passwordStore_[property]] = true; } return Object.keys(passwords); }, /** * Removes the injected content script and unbinds all listeners from the * webview passed to the constructor. This SAMLHandler will be unusable * after this function returns. */ unbindFromWebview: function() { this.webview_.removeContentScripts([injectedScriptName]); this.webviewEventManager_.removeAllListeners(); }, /** * Resets all auth states */ reset: function() { this.isSamlPage_ = false; this.pendingIsSamlPage_ = false; this.passwordStore_ = {}; this.apiInitialized_ = false; this.apiVersion_ = 0; this.apiToken_ = null; this.apiPasswordBytes_ = null; }, /** * Check whether the given |password| is in the scraped passwords. * @return {boolean} True if the |password| is found. */ verifyConfirmedPassword: function(password) { return this.getConsolidatedScrapedPasswords_().indexOf(password) >= 0; }, /** * Invoked on the webview's contentload event. * @private */ onContentLoad_: function(e) { // |this.webview_.contentWindow| may be null after network error screen // is shown. See crbug.com/770999. if (this.webview_.contentWindow) PostMessageChannel.init(this.webview_.contentWindow); else console.error('SamlHandler.onContentLoad_: contentWindow is null.'); }, /** * Invoked on the webview's loadabort event. * @private */ onLoadAbort_: function(e) { if (e.isTopLevel) this.abortedTopLevelUrl_ = e.url; }, /** * Invoked on the webview's loadcommit event for both main and sub frames. * @private */ onLoadCommit_: function(e) { // Skip this loadcommit if the top level load is just aborted. if (e.isTopLevel && e.url === this.abortedTopLevelUrl_) { this.abortedTopLevelUrl_ = null; return; } // Skip for none http/https url. if (!e.url.startsWith('https://') && !e.url.startsWith('http://')) return; this.isSamlPage_ = this.pendingIsSamlPage_; }, /** * 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 {Object} details * @return {!Object} Decision whether to block the request. */ onInsecureRequest: function(details) { if (!this.blockInsecureContent) return {}; var strippedUrl = stripParams(details.url); this.dispatchEvent(new CustomEvent( 'insecureContentBlocked', {detail: {url: strippedUrl}})); return {cancel: true}; }, /** * Invoked when headers are received for the main frame. * @private */ onHeadersReceived_: function(details) { var headers = details.responseHeaders; // 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) { var header = headers[i]; var headerName = header.name.toLowerCase(); if (headerName == SAML_HEADER) { var action = header.value.toLowerCase(); if (action == 'start') { this.pendingIsSamlPage_ = 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.pendingIsSamlPage_ = 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().startsWith('set-cookie')) { var header = headers[j]; header.value += ';'; cookies.push(header); } else { otherHeaders.push(headers[j]); } } return {responseHeaders: otherHeaders.concat(cookies)}; } } } return {}; }, /** * Invoked when the injected JS makes a connection. */ onConnected_: function(port) { if (port.targetWindow != this.webview_.contentWindow) return; var channel = Channel.create(); channel.init(port); channel.registerMessage('apiCall', this.onAPICall_.bind(this, channel)); channel.registerMessage( 'updatePassword', this.onUpdatePassword_.bind(this, channel)); channel.registerMessage( 'pageLoaded', this.onPageLoaded_.bind(this, channel)); channel.registerMessage( 'getSAMLFlag', this.onGetSAMLFlag_.bind(this, channel)); }, sendInitializationSuccess_: function(channel) { channel.send({ name: 'apiResponse', response: { result: 'initialized', version: this.apiVersion_, keyTypes: API_KEY_TYPES } }); }, sendInitializationFailure_: function(channel) { channel.send( {name: 'apiResponse', response: {result: 'initialization_failed'}}); }, /** * Handlers for channel messages. * @param {Channel} channel A channel to send back response. * @param {Object} msg Received message. * @private */ onAPICall_: function(channel, msg) { var call = msg.call; if (call.method == 'initialize') { if (!Number.isInteger(call.requestedVersion) || call.requestedVersion < MIN_API_VERSION_VERSION) { this.sendInitializationFailure_(channel); return; } this.apiVersion_ = Math.min(call.requestedVersion, MAX_API_VERSION_VERSION); this.apiInitialized_ = true; this.sendInitializationSuccess_(channel); return; } if (call.method == 'add') { if (API_KEY_TYPES.indexOf(call.keyType) == -1) { console.error('SamlHandler.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.apiPasswordBytes_ = call.passwordBytes; this.dispatchEvent(new CustomEvent('apiPasswordAdded')); } else if (call.method == 'confirm') { if (call.token != this.apiToken_) console.error('SamlHandler.onAPICall_: token mismatch'); } else { console.error('SamlHandler.onAPICall_: unknown message'); } }, onUpdatePassword_: function(channel, msg) { if (this.isSamlPage_) this.passwordStore_[msg.id] = msg.password; }, onPageLoaded_: function(channel, msg) { this.authDomain = extractDomain(msg.url); this.dispatchEvent(new CustomEvent('authPageLoaded', { detail: { url: url, isSAMLPage: this.isSamlPage_, domain: this.authDomain } })); }, onPermissionRequest_: function(permissionEvent) { if (permissionEvent.permission === 'media') { // The actual permission check happens in // WebUILoginView::RequestMediaAccessPermission(). this.dispatchEvent(new CustomEvent('videoEnabled')); permissionEvent.request.allow(); } }, onGetSAMLFlag_: function(channel, msg) { return this.isSamlPage_; }, }; return {SamlHandler: SamlHandler}; }); // Note: webview_event_manager.js is already included by saml_handler.js. /** * @fileoverview An UI component to authenciate to Chrome. The component hosts * IdP web pages in a webview. A client who is interested in monitoring * authentication events should pass a listener object of type * cr.login.GaiaAuthHost.Listener as defined in this file. After initialization, * call {@code load} to start the authentication flow. * * See go/cros-auth-design for details on Google API. */ cr.define('cr.login', function() { 'use strict'; // TODO(rogerta): should use gaia URL from GaiaUrls::gaia_url() instead // of hardcoding the prod URL here. As is, this does not work with staging // environments. var IDP_ORIGIN = 'https://accounts.google.com/'; var IDP_PATH = 'ServiceLogin?skipvpage=true&sarp=1&rm=hide'; var CONTINUE_URL = 'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik/success.html'; var SIGN_IN_HEADER = 'google-accounts-signin'; var EMBEDDED_FORM_HEADER = 'google-accounts-embedded'; var LOCATION_HEADER = 'location'; var COOKIE_HEADER = 'cookie'; var SET_COOKIE_HEADER = 'set-cookie'; var OAUTH_CODE_COOKIE = 'oauth_code'; var GAPS_COOKIE = 'GAPS'; var SERVICE_ID = 'chromeoslogin'; var EMBEDDED_SETUP_CHROMEOS_ENDPOINT = 'embedded/setup/chromeos'; var EMBEDDED_SETUP_CHROMEOS_ENDPOINT_V2 = 'embedded/setup/v2/chromeos'; var SAML_REDIRECTION_PATH = 'samlredirect'; var BLANK_PAGE_URL = 'about:blank'; /** * The source URL parameter for the constrained signin flow. */ var CONSTRAINED_FLOW_SOURCE = 'chrome'; /** * Enum for the authorization mode, must match AuthMode defined in * chrome/browser/ui/webui/inline_login_ui.cc. * @enum {number} */ var AuthMode = {DEFAULT: 0, OFFLINE: 1, DESKTOP: 2}; /** * Enum for the authorization type. * @enum {number} */ var AuthFlow = {DEFAULT: 0, SAML: 1}; /** * Supported Authenticator params. * @type {!Array} * @const */ var SUPPORTED_PARAMS = [ 'gaiaId', // Obfuscated GAIA ID to skip the email prompt page // during the re-auth flow. 'gaiaUrl', // Gaia url to use. 'gaiaPath', // Gaia path to use without a leading slash. 'hl', // Language code for the user interface. 'service', // Name of Gaia service. 'continueUrl', // Continue url to use. 'frameUrl', // Initial frame URL to use. If empty defaults to // gaiaUrl. 'constrained', // Whether the extension is loaded in a constrained // window. 'clientId', // Chrome client id. 'needPassword', // Whether the host is interested in getting a password. // If this set to |false|, |confirmPasswordCallback| is // not called before dispatching |authCopleted|. // Default is |true|. 'flow', // One of 'default', 'enterprise', or 'theftprotection'. 'enterpriseEnrollmentDomain', // Domain in which hosting device is (or // should be) enrolled. 'emailDomain', // Value used to prefill domain for email. 'chromeType', // Type of Chrome OS device, e.g. "chromebox". 'clientVersion', // Version of the Chrome build. 'platformVersion', // Version of the OS build. 'releaseChannel', // Installation channel. 'endpointGen', // Current endpoint generation. 'gapsCookie', // GAPS cookie 'chromeOSApiVersion', // GAIA Chrome OS API version 'menuGuestMode', // Enables "Guest mode" menu item 'menuKeyboardOptions', // Enables "Keyboard options" menu item 'menuEnterpriseEnrollment', // Enables "Enterprise enrollment" menu item. 'lsbReleaseBoard', // Chrome OS Release board name 'isFirstUser', // True if this is non-enterprise device, // and there are no users yet. // The email fields allow for the following possibilities: // // 1/ If 'email' is not supplied, then the email text field is blank and the // user must type an email to proceed. // // 2/ If 'email' is supplied, and 'readOnlyEmail' is truthy, then the email // is hardcoded and the user cannot change it. The user is asked for // password. This is useful for re-auth scenarios, where chrome needs the // user to authenticate for a specific account and only that account. // // 3/ If 'email' is supplied, and 'readOnlyEmail' is falsy, gaia will // prefill the email text field using the given email address, but the user // can still change it and then proceed. This is used on desktop when the // user disconnects their profile then reconnects, to encourage them to use // the same account. 'email', 'readOnlyEmail', 'realm', ]; /** * Initializes the authenticator component. * @param {webview|string} webview The webview element or its ID to host IdP * web pages. * @constructor */ function Authenticator(webview) { this.isLoaded_ = false; this.email_ = null; this.password_ = null; this.gaiaId_ = null, this.sessionIndex_ = null; this.chooseWhatToSync_ = false; this.skipForNow_ = false; this.authFlow = AuthFlow.DEFAULT; this.authDomain = ''; this.videoEnabled = false; this.idpOrigin_ = null; this.continueUrl_ = null; this.continueUrlWithoutParams_ = null; this.initialFrameUrl_ = null; this.reloadUrl_ = null; this.trusted_ = true; this.oauthCode_ = null; this.gapsCookie_ = null; this.gapsCookieSent_ = false; this.newGapsCookie_ = null; this.readyFired_ = false; this.webviewEventManager_ = WebviewEventManager.create(); this.clientId_ = null; this.confirmPasswordCallback = null; this.noPasswordCallback = null; this.insecureContentBlockedCallback = null; this.samlApiUsedCallback = null; this.missingGaiaInfoCallback = null; this.needPassword = true; this.bindToWebview_(webview); window.addEventListener( 'message', this.onMessageFromWebview_.bind(this), false); window.addEventListener('focus', this.onFocus_.bind(this), false); window.addEventListener('popstate', this.onPopState_.bind(this), false); } Authenticator.prototype = Object.create(cr.EventTarget.prototype); /** * Reinitializes authentication parameters so that a failed login attempt * would not result in an infinite loop. */ Authenticator.prototype.resetStates = function() { this.isLoaded_ = false; this.email_ = null; this.gaiaId_ = null; this.password_ = null; this.oauthCode_ = null; this.gapsCookie_ = null; this.gapsCookieSent_ = false; this.newGapsCookie_ = null; this.readyFired_ = false; this.chooseWhatToSync_ = false; this.skipForNow_ = false; this.sessionIndex_ = null; this.trusted_ = true; this.authFlow = AuthFlow.DEFAULT; this.samlHandler_.reset(); this.videoEnabled = false; }; /** * Resets the webview to the blank page. */ Authenticator.prototype.resetWebview = function() { if (this.webview_.src && this.webview_.src != BLANK_PAGE_URL) this.webview_.src = BLANK_PAGE_URL; }; /** * Binds this authenticator to the passed webview. * @param {!Object} webview the new webview to be used by this Authenticator. * @private */ Authenticator.prototype.bindToWebview_ = function(webview) { assert(!this.webview_); assert(!this.samlHandler_); this.webview_ = typeof webview == 'string' ? $(webview) : webview; this.samlHandler_ = new cr.login.SamlHandler(this.webview_); this.webviewEventManager_.addEventListener( this.samlHandler_, 'insecureContentBlocked', this.onInsecureContentBlocked_.bind(this)); this.webviewEventManager_.addEventListener( this.samlHandler_, 'authPageLoaded', this.onAuthPageLoaded_.bind(this)); this.webviewEventManager_.addEventListener( this.samlHandler_, 'videoEnabled', this.onVideoEnabled_.bind(this)); this.webviewEventManager_.addEventListener( this.samlHandler_, 'apiPasswordAdded', this.onSamlApiPasswordAdded_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'droplink', this.onDropLink_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'newwindow', this.onNewWindow_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'contentload', this.onContentLoad_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'loadabort', this.onLoadAbort_.bind(this)); this.webviewEventManager_.addEventListener( this.webview_, 'loadcommit', this.onLoadCommit_.bind(this)); this.webviewEventManager_.addWebRequestEventListener( this.webview_.request.onCompleted, this.onRequestCompleted_.bind(this), {urls: [''], types: ['main_frame']}, ['responseHeaders']); this.webviewEventManager_.addWebRequestEventListener( this.webview_.request.onHeadersReceived, this.onHeadersReceived_.bind(this), {urls: [''], types: ['main_frame', 'xmlhttprequest']}, ['responseHeaders']); }; /** * Unbinds this Authenticator from the currently bound webview. * @private */ Authenticator.prototype.unbindFromWebview_ = function() { assert(this.webview_); assert(this.samlHandler_); this.webviewEventManager_.removeAllListeners(); this.webview_ = undefined; this.samlHandler_.unbindFromWebview(); this.samlHandler_ = undefined; }; /** * Re-binds to another webview. * @param {Object} webview the new webview to be used by this Authenticator. */ Authenticator.prototype.rebindWebview = function(webview) { this.unbindFromWebview_(); this.bindToWebview_(webview); }; /** * Loads the authenticator component with the given parameters. * @param {AuthMode} authMode Authorization mode. * @param {Object} data Parameters for the authorization flow. */ Authenticator.prototype.load = function(authMode, data) { this.authMode = authMode; this.resetStates(); // gaiaUrl parameter is used for testing. Once defined, it is never changed. this.idpOrigin_ = data.gaiaUrl || IDP_ORIGIN; this.continueUrl_ = data.continueUrl || CONTINUE_URL; this.continueUrlWithoutParams_ = this.continueUrl_.substring(0, this.continueUrl_.indexOf('?')) || this.continueUrl_; this.isConstrainedWindow_ = data.constrained == '1'; this.isNewGaiaFlow = data.isNewGaiaFlow; this.clientId_ = data.clientId; this.gapsCookie_ = data.gapsCookie; this.gapsCookieSent_ = false; this.newGapsCookie_ = null; this.dontResizeNonEmbeddedPages = data.dontResizeNonEmbeddedPages; this.chromeOSApiVersion_ = data.chromeOSApiVersion; this.initialFrameUrl_ = this.constructInitialFrameUrl_(data); this.reloadUrl_ = data.frameUrl || this.initialFrameUrl_; // Don't block insecure content for desktop flow because it lands on // http. Otherwise, block insecure content as long as gaia is https. this.samlHandler_.blockInsecureContent = authMode != AuthMode.DESKTOP && this.idpOrigin_.startsWith('https://'); this.needPassword = !('needPassword' in data) || data.needPassword; if (this.isNewGaiaFlow) { this.webview_.contextMenus.onShow.addListener(function(e) { e.preventDefault(); }); if (!this.onBeforeSetHeadersSet_) { this.onBeforeSetHeadersSet_ = true; var filterPrefix = this.constructChromeOSAPIUrl_(); // This depends on gaiaUrl parameter, that is why it is here. this.webview_.request.onBeforeSendHeaders.addListener( this.onBeforeSendHeaders_.bind(this), {urls: [filterPrefix + '?*', filterPrefix + '/*']}, ['requestHeaders', 'blocking']); } } this.webview_.src = this.reloadUrl_; this.isLoaded_ = true; }; Authenticator.prototype.constructChromeOSAPIUrl_ = function() { if (this.chromeOSApiVersion_ && this.chromeOSApiVersion_ == 2) return this.idpOrigin_ + EMBEDDED_SETUP_CHROMEOS_ENDPOINT_V2; return this.idpOrigin_ + EMBEDDED_SETUP_CHROMEOS_ENDPOINT; }; /** * Reloads the authenticator component. */ Authenticator.prototype.reload = function() { this.resetStates(); this.webview_.src = this.reloadUrl_; this.isLoaded_ = true; }; Authenticator.prototype.constructInitialFrameUrl_ = function(data) { if (data.doSamlRedirect) { var url = this.idpOrigin_ + SAML_REDIRECTION_PATH; url = appendParam(url, 'domain', data.enterpriseEnrollmentDomain); url = appendParam( url, 'continue', data.gaiaUrl + 'programmatic_auth_chromeos?hl=' + data.hl + '&scope=https%3A%2F%2Fwww.google.com%2Faccounts%2FOAuthLogin&' + 'client_id=' + encodeURIComponent(data.clientId) + '&access_type=offline'); return url; } var url; if (data.gaiaPath) url = this.idpOrigin_ + data.gaiaPath; else if (this.isNewGaiaFlow) url = this.constructChromeOSAPIUrl_(); else url = this.idpOrigin_ + IDP_PATH; if (this.isNewGaiaFlow) { if (data.chromeType) url = appendParam(url, 'chrometype', data.chromeType); if (data.clientId) url = appendParam(url, 'client_id', data.clientId); if (data.enterpriseEnrollmentDomain) url = appendParam(url, 'manageddomain', data.enterpriseEnrollmentDomain); if (data.clientVersion) url = appendParam(url, 'client_version', data.clientVersion); if (data.platformVersion) url = appendParam(url, 'platform_version', data.platformVersion); if (data.releaseChannel) url = appendParam(url, 'release_channel', data.releaseChannel); if (data.endpointGen) url = appendParam(url, 'endpoint_gen', data.endpointGen); if (data.chromeOSApiVersion == 2) { var mi = ''; if (data.menuGuestMode) mi += 'gm,'; if (data.menuKeyboardOptions) mi += 'ko,'; if (data.menuEnterpriseEnrollment) mi += 'ee,'; if (mi.length) url = appendParam(url, 'mi', mi); if (data.lsbReleaseBoard) url = appendParam(url, 'chromeos_board', data.lsbReleaseBoard); if (data.isFirstUser) url = appendParam(url, 'is_first_user', true); } } else { url = appendParam(url, 'continue', this.continueUrl_); url = appendParam(url, 'service', data.service || SERVICE_ID); } if (data.hl) url = appendParam(url, 'hl', data.hl); if (data.gaiaId) url = appendParam(url, 'user_id', data.gaiaId); if (data.email) { if (data.readOnlyEmail) { url = appendParam(url, 'Email', data.email); } else { url = appendParam(url, 'email_hint', data.email); } } if (this.isConstrainedWindow_) url = appendParam(url, 'source', CONSTRAINED_FLOW_SOURCE); if (data.flow) url = appendParam(url, 'flow', data.flow); if (data.emailDomain) url = appendParam(url, 'emaildomain', data.emailDomain); return url; }; /** * Dispatches the 'ready' event if it hasn't been dispatched already for the * current content. * @private */ Authenticator.prototype.fireReadyEvent_ = function() { if (!this.readyFired_) { this.dispatchEvent(new Event('ready')); this.readyFired_ = true; } }; /** * Invoked when a main frame request in the webview has completed. * @private */ Authenticator.prototype.onRequestCompleted_ = function(details) { var currentUrl = details.url; if (!this.isNewGaiaFlow && currentUrl.lastIndexOf(this.continueUrlWithoutParams_, 0) == 0) { if (currentUrl.indexOf('ntp=1') >= 0) this.skipForNow_ = true; this.maybeCompleteAuth_(); return; } if (!currentUrl.startsWith('https')) this.trusted_ = false; if (this.isConstrainedWindow_) { var isEmbeddedPage = false; if (this.idpOrigin_ && currentUrl.lastIndexOf(this.idpOrigin_) == 0) { var headers = details.responseHeaders; for (var i = 0; headers && i < headers.length; ++i) { if (headers[i].name.toLowerCase() == EMBEDDED_FORM_HEADER) { isEmbeddedPage = true; break; } } } // In some cases, non-embedded pages should not be resized. For // example, on desktop when reauthenticating for purposes of unlocking // a profile, resizing would cause a browser window to open in the // system profile, which is not allowed. if (!isEmbeddedPage && !this.dontResizeNonEmbeddedPages) { this.dispatchEvent(new CustomEvent('resize', {detail: currentUrl})); return; } } this.updateHistoryState_(currentUrl); }; /** * Manually updates the history. Invoked upon completion of a webview * navigation. * @param {string} url Request URL. * @private */ Authenticator.prototype.updateHistoryState_ = function(url) { if (history.state && history.state.url != url) history.pushState({url: url}, ''); else history.replaceState({url: url}, ''); }; /** * Invoked when the sign-in page takes focus. * @param {object} e The focus event being triggered. * @private */ Authenticator.prototype.onFocus_ = function(e) { if (this.authMode == AuthMode.DESKTOP && document.activeElement == document.body) { this.webview_.focus(); } }; /** * Invoked when the history state is changed. * @param {object} e The popstate event being triggered. * @private */ Authenticator.prototype.onPopState_ = function(e) { var state = e.state; if (state && state.url) this.webview_.src = state.url; }; /** * Invoked when headers are received in the main frame of the webview. It * 1) reads the authenticated user info from a signin header, * 2) signals the start of a saml flow upon receiving a saml header. * @return {!Object} Modified request headers. * @private */ Authenticator.prototype.onHeadersReceived_ = function(details) { var currentUrl = details.url; if (currentUrl.lastIndexOf(this.idpOrigin_, 0) != 0) return; var headers = details.responseHeaders; for (var i = 0; headers && i < headers.length; ++i) { var header = headers[i]; var headerName = header.name.toLowerCase(); if (headerName == SIGN_IN_HEADER) { var headerValues = header.value.toLowerCase().split(','); var signinDetails = {}; headerValues.forEach(function(e) { var pair = e.split('='); signinDetails[pair[0].trim()] = pair[1].trim(); }); // Removes "" around. this.email_ = signinDetails['email'].slice(1, -1); this.gaiaId_ = signinDetails['obfuscatedid'].slice(1, -1); this.sessionIndex_ = signinDetails['sessionindex']; } else if (headerName == LOCATION_HEADER) { // If the "choose what to sync" checkbox was clicked, then the continue // URL will contain a source=3 field. var location = decodeURIComponent(header.value); this.chooseWhatToSync_ = !!location.match(/(\?|&)source=3($|&)/); } else if (this.isNewGaiaFlow && headerName == SET_COOKIE_HEADER) { var headerValue = header.value; if (headerValue.startsWith(OAUTH_CODE_COOKIE + '=')) { this.oauthCode_ = headerValue.substring(OAUTH_CODE_COOKIE.length + 1).split(';')[0]; } if (headerValue.startsWith(GAPS_COOKIE + '=')) { this.newGapsCookie_ = headerValue.substring(GAPS_COOKIE.length + 1).split(';')[0]; } } } }; /** * This method replaces cookie value in cookie header. * @param@ {string} header_value Original string value of Cookie header. * @param@ {string} cookie_name Name of cookie to be replaced. * @param@ {string} cookie_value New cookie value. * @return {string} New Cookie header value. * @private */ Authenticator.prototype.updateCookieValue_ = function( header_value, cookie_name, cookie_value) { var cookies = header_value.split(/\s*;\s*/); var found = false; for (var i = 0; i < cookies.length; ++i) { if (cookies[i].startsWith(cookie_name + '=')) { found = true; cookies[i] = cookie_name + '=' + cookie_value; break; } } if (!found) { cookies.push(cookie_name + '=' + cookie_value); } return cookies.join('; '); }; /** * Handler for webView.request.onBeforeSendHeaders . * @return {!Object} Modified request headers. * @private */ Authenticator.prototype.onBeforeSendHeaders_ = function(details) { // We should re-send cookie if first request was unsuccessful (i.e. no new // GAPS cookie was received). if (this.isNewGaiaFlow && this.gapsCookie_ && (!this.gapsCookieSent_ || !this.newGapsCookie_)) { var headers = details.requestHeaders; var found = false; var gapsCookie = this.gapsCookie_; for (var i = 0, l = headers.length; i < l; ++i) { if (headers[i].name == COOKIE_HEADER) { headers[i].value = this.updateCookieValue_( headers[i].value, GAPS_COOKIE, gapsCookie); found = true; break; } } if (!found) { details.requestHeaders.push( {name: COOKIE_HEADER, value: GAPS_COOKIE + '=' + gapsCookie}); } this.gapsCookieSent_ = true; } return {requestHeaders: details.requestHeaders}; }; /** * Returns true if given HTML5 message is received from the webview element. * @param {object} e Payload of the received HTML5 message. */ Authenticator.prototype.isGaiaMessage = function(e) { if (!this.isWebviewEvent_(e)) return false; // The event origin does not have a trailing slash. if (e.origin != this.idpOrigin_.substring(0, this.idpOrigin_.length - 1)) { return false; } // Gaia messages must be an object with 'method' property. if (typeof e.data != 'object' || !e.data.hasOwnProperty('method')) { return false; } return true; }; /** * Invoked when an HTML5 message is received from the webview element. * @param {object} e Payload of the received HTML5 message. * @private */ Authenticator.prototype.onMessageFromWebview_ = function(e) { if (!this.isGaiaMessage(e)) return; var msg = e.data; if (msg.method == 'attemptLogin') { this.email_ = msg.email; if (this.authMode == AuthMode.DESKTOP) this.password_ = msg.password; this.chooseWhatToSync_ = msg.chooseWhatToSync; // We need to dispatch only first event, before user enters password. this.dispatchEvent(new CustomEvent('attemptLogin', {detail: msg.email})); } else if (msg.method == 'dialogShown') { this.dispatchEvent(new Event('dialogShown')); } else if (msg.method == 'dialogHidden') { this.dispatchEvent(new Event('dialogHidden')); } else if (msg.method == 'backButton') { this.dispatchEvent(new CustomEvent('backButton', {detail: msg.show})); } else if (msg.method == 'showView') { this.dispatchEvent(new Event('showView')); } else if (msg.method == 'menuItemClicked') { this.dispatchEvent( new CustomEvent('menuItemClicked', {detail: msg.item})); } else if (msg.method == 'identifierEntered') { this.dispatchEvent(new CustomEvent( 'identifierEntered', {detail: {accountIdentifier: msg.accountIdentifier}})); } else { console.warn('Unrecognized message from GAIA: ' + msg.method); } }; /** * Invoked by the hosting page to verify the Saml password. */ Authenticator.prototype.verifyConfirmedPassword = function(password) { if (!this.samlHandler_.verifyConfirmedPassword(password)) { // Invoke confirm password callback asynchronously because the // verification was based on messages and caller (GaiaSigninScreen) // does not expect it to be called immediately. // TODO(xiyuan): Change to synchronous call when iframe based code // is removed. var invokeConfirmPassword = (function() { this.confirmPasswordCallback( this.email_, this.samlHandler_.scrapedPasswordCount); }).bind(this); window.setTimeout(invokeConfirmPassword, 0); return; } this.password_ = password; this.onAuthCompleted_(); }; /** * Check Saml flow and start password confirmation flow if needed. Otherwise, * continue with auto completion. * @private */ Authenticator.prototype.maybeCompleteAuth_ = function() { var missingGaiaInfo = !this.email_ || !this.gaiaId_ || !this.sessionIndex_; if (missingGaiaInfo && !this.skipForNow_) { if (this.missingGaiaInfoCallback) this.missingGaiaInfoCallback(); this.webview_.src = this.initialFrameUrl_; return; } if (this.samlHandler_.samlApiUsed) { if (this.samlApiUsedCallback) { this.samlApiUsedCallback(); } this.password_ = this.samlHandler_.apiPasswordBytes; this.onAuthCompleted_(); return; } if (this.samlHandler_.scrapedPasswordCount == 0) { if (this.noPasswordCallback) { this.noPasswordCallback(this.email_); return; } // Fall through to finish the auth flow even if this.needPassword // is true. This is because the flag is used as an intention to get // password when it is available but not a mandatory requirement. console.warn('Authenticator: No password scraped for SAML.'); } else if (this.needPassword) { if (this.samlHandler_.scrapedPasswordCount == 1) { // If we scraped exactly one password, we complete the authentication // right away. this.password_ = this.samlHandler_.firstScrapedPassword; this.onAuthCompleted_(); return; } if (this.confirmPasswordCallback) { // Confirm scraped password. The flow follows in // verifyConfirmedPassword. this.confirmPasswordCallback( this.email_, this.samlHandler_.scrapedPasswordCount); return; } } this.onAuthCompleted_(); }; /** * Invoked to complete the authentication using the password the user enters * manually for non-principals API SAML IdPs that we couldn't scrape their * password input. */ Authenticator.prototype.completeAuthWithManualPassword = function(password) { this.password_ = password; this.onAuthCompleted_(); }; /** * Invoked to process authentication completion. * @private */ Authenticator.prototype.onAuthCompleted_ = function() { assert( this.skipForNow_ || (this.email_ && this.gaiaId_ && this.sessionIndex_)); this.dispatchEvent(new CustomEvent( 'authCompleted', // TODO(rsorokin): get rid of the stub values. { detail: { email: this.email_ || '', gaiaId: this.gaiaId_ || '', password: this.password_ || '', authCode: this.oauthCode_, usingSAML: this.authFlow == AuthFlow.SAML, chooseWhatToSync: this.chooseWhatToSync_, skipForNow: this.skipForNow_, sessionIndex: this.sessionIndex_ || '', trusted: this.trusted_, gapsCookie: this.newGapsCookie_ || this.gapsCookie_ || '', } })); this.resetStates(); }; /** * Invoked when |samlHandler_| fires 'insecureContentBlocked' event. * @private */ Authenticator.prototype.onInsecureContentBlocked_ = function(e) { if (!this.isLoaded_) return; if (this.insecureContentBlockedCallback) this.insecureContentBlockedCallback(e.detail.url); else console.error('Authenticator: Insecure content blocked.'); }; /** * Invoked when |samlHandler_| fires 'authPageLoaded' event. * @private */ Authenticator.prototype.onAuthPageLoaded_ = function(e) { if (!this.isLoaded_) return; if (!e.detail.isSAMLPage) return; this.authDomain = this.samlHandler_.authDomain; this.authFlow = AuthFlow.SAML; this.fireReadyEvent_(); }; /** * Invoked when |samlHandler_| fires 'videoEnabled' event. * @private */ Authenticator.prototype.onVideoEnabled_ = function(e) { this.videoEnabled = true; }; /** * Invoked when |samlHandler_| fires 'apiPasswordAdded' event. * @private */ Authenticator.prototype.onSamlApiPasswordAdded_ = function(e) { // Saml API 'add' password might be received after the 'loadcommit' event. // In such case, maybeCompleteAuth_ should be attempted again if oauth code // is available. if (this.oauthCode_) this.maybeCompleteAuth_(); }; /** * Invoked when a link is dropped on the webview. * @private */ Authenticator.prototype.onDropLink_ = function(e) { this.dispatchEvent(new CustomEvent('dropLink', {detail: e.url})); }; /** * Invoked when the webview attempts to open a new window. * @private */ Authenticator.prototype.onNewWindow_ = function(e) { this.dispatchEvent(new CustomEvent('newWindow', {detail: e})); }; /** * Invoked when a new document is loaded. * @private */ Authenticator.prototype.onContentLoad_ = function(e) { if (this.isConstrainedWindow_) { // Signin content in constrained windows should not zoom. Isolate the // webview from the zooming of other webviews using the 'per-view' zoom // mode, and then set it to 100% zoom. this.webview_.setZoomMode('per-view'); this.webview_.setZoom(1); } // Posts a message to IdP pages to initiate communication. var currentUrl = this.webview_.src; if (currentUrl.lastIndexOf(this.idpOrigin_) == 0) { var msg = { 'method': 'handshake', }; // |this.webview_.contentWindow| may be null after network error screen // is shown. See crbug.com/770999. if (this.webview_.contentWindow) this.webview_.contentWindow.postMessage(msg, currentUrl); else console.error('Authenticator: contentWindow is null.'); if (this.authMode == AuthMode.DEFAULT) { chrome.send('metricsHandler:recordBooleanHistogram', [ 'ChromeOS.GAIA.AuthenticatorContentWindowNull', !this.webview_.contentWindow ]); } this.fireReadyEvent_(); // Focus webview after dispatching event when webview is already visible. this.webview_.focus(); } else if (currentUrl == BLANK_PAGE_URL) { this.fireReadyEvent_(); } }; /** * Invoked when the webview fails loading a page. * @private */ Authenticator.prototype.onLoadAbort_ = function(e) { this.dispatchEvent( new CustomEvent('loadAbort', {detail: {error: e.reason, src: e.url}})); }; /** * Invoked when the webview navigates withing the current document. * @private */ Authenticator.prototype.onLoadCommit_ = function(e) { if (this.oauthCode_) this.maybeCompleteAuth_(); }; /** * Returns |true| if event |e| was sent from the hosted webview. * @private */ Authenticator.prototype.isWebviewEvent_ = function(e) { // Note: prints error message to console if |contentWindow| is not // defined. // TODO(dzhioev): remove the message. http://crbug.com/469522 var webviewWindow = this.webview_.contentWindow; return !!webviewWindow && webviewWindow === e.source; }; /** * The current auth flow of the hosted auth page. * @type {AuthFlow} */ cr.defineProperty(Authenticator, 'authFlow'); /** * The domain name of the current auth page. * @type {string} */ cr.defineProperty(Authenticator, 'authDomain'); /** * True if the page has requested media access. * @type {boolean} */ cr.defineProperty(Authenticator, 'videoEnabled'); Authenticator.AuthFlow = AuthFlow; Authenticator.AuthMode = AuthMode; Authenticator.SUPPORTED_PARAMS = SUPPORTED_PARAMS; return { // TODO(guohui, xiyuan): Rename GaiaAuthHost to Authenticator once the old // iframe-based flow is deprecated. GaiaAuthHost: Authenticator, Authenticator: Authenticator }; }); /* 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. */ * { box-sizing: border-box; } html { height: 100%; } body { color: rgb(48, 57, 66); display: flex; flex-direction: column; font-size: 13px; height: 100%; margin: 0; overflow: auto; } .hidden { display: none !important; } img { flex-shrink: 0; height: 16px; padding-left: 2px; padding-right: 5px; vertical-align: top; width: 23px; } #container { display: flex; height: 100% } #infobar { background: rgb(255, 212, 0); display: none; padding: 4px 0; text-align: center; } #infobar.show { display: block; } #navigation { flex-shrink: 0; padding-top: 20px; width: 150px; } #content { flex-grow: 1; } #caption { color: rgb(92, 97, 102); font-size: 150%; padding-bottom: 10px; padding-left: 20px; } #serviceworker-internals { visibility: hidden; } .tab-header { -webkit-border-start: 6px solid transparent; padding-left: 15px; } .tab-header.selected { -webkit-border-start-color: rgb(78, 87, 100); } .tab-header > button { background-color: white; border: 0; cursor: pointer; font: inherit; line-height: 17px; margin: 6px 0; padding: 0 2px; } .tab-header:not(.selected) > button { color: #999; } #content > div { min-width: 32em; padding: 0 20px 65px 0; } #content > div:not(.selected) { display: none; } .content-header { background: linear-gradient(white, white 40%, rgba(255, 255, 255, 0.92)); border-bottom: 1px solid #eee; font-size: 150%; padding: 20px 0 10px 0; z-index: 1; } #devices-help { margin-top: 10px; } .device-header { -webkit-box-align: baseline; -webkit-box-orient: horizontal; display: -webkit-box; margin: 10px 0 0; padding: 2px 0; } .device-name { font-size: 150%; } .device-serial { color: #999; font-size: 80%; margin-left: 6px; } .device-ports { -webkit-box-orient: horizontal; display: -webkit-box; margin-left: 8px; } .port-icon { background-color: rgb(64, 192, 64); border: 0 solid transparent; border-radius: 6px; height: 12px; margin: 2px; width: 12px; } .port-icon.error { background-color: rgb(224, 32, 32); } .port-icon.transient { background-color: orange; transform: scale(1.2); } .port-number { height: 16px; margin-right: 5px; } .browser-header { align-items: center; display: flex; flex-flow: row wrap; min-height: 33px; padding-top: 10px; } .browser-header > .browser-name { font-size: 110%; font-weight: bold; } .browser-header > .browser-user { color: #999; margin-left: 6px; } .used-for-port-forwarding { background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOHB4IiBoZWlnaHQ9IjE4cHgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0iIzc1NzU3NSI+CiAgICA8cGF0aCBkPSJNMCAwaDQ4djQ4SDB6IiBmaWxsPSJub25lIi8+CiAgICA8cGF0aCBkPSJNMjQgNEMxMi45NSA0IDQgMTIuOTUgNCAyNHM4Ljk1IDIwIDIwIDIwIDIwLTguOTUgMjAtMjBTMzUuMDUgNCAyNCA0em0yIDMwaC00VjIyaDR2MTJ6bTAtMTZoLTR2LTRoNHY0eiIvPgo8L3N2Zz4K); height: 15px; margin-left: 20px; width: 15px; } .row { padding: 6px 0; position: relative; } .properties-box { display: flex; } .subrow-box { display: inline-block; vertical-align: top; } .subrow { display: flex; flex-flow: row wrap; } .subrow > div { margin-right: 0.5em; } .webview-thumbnail { display: inline-block; flex-shrink: 0; margin-right: 5px; overflow: hidden; position: relative; vertical-align: top; } .screen-rect { background-color: #eee; position: absolute; } .view-rect { background-color: #ccc; min-height: 1px; min-width: 1px; position: absolute; } .view-rect.hidden { background-color: #ddd; } .guest { padding-left: 20px; } .invisible-view { color: rgb(151, 156, 160); } .url { color: #999; } .list { margin-top: 5px; } .action { color: rgb(17, 85, 204); cursor: pointer; margin-right: 15px; } .action:hover { text-decoration: underline; } .browser-header .action { margin-left: 10px; } .list:not(.pages) .subrow { min-height: 19px; } .action.disabled { opacity: 0.5; pointer-events: none; } .open > input { border: 1px solid #aaa; height: 17px; line-height: 17px; margin-left: 20px; padding: 0 2px; } .open > input:focus { border-color: rgb(77, 144, 254); outline: none; transition: border-color 200ms; } .open > button { line-height: 13px; } #device-settings { border-bottom: 1px solid #eee; padding: 5px 0; } .settings-bar { padding: 5px 0 5px 0; } .settings-bar label { display: inline-block; width: 35ex; } .node-frontend-action { margin: 6px 4px; } dialog.config::backdrop { background-color: rgba(255, 255, 255, 0.75); } dialog.config { background: white; border: 0; border-radius: 3px; box-shadow: 0 4px 23px 5px rgba(0, 0, 0, 0.2), 0 2px 6px rgba(0,0,0,0.15); color: #333; padding: 17px 17px 12px; position: relative; } #port-forwarding-enable { vertical-align: middle; } .close-button { background-image: url(chrome://theme/IDR_CLOSE_DIALOG); height: 14px; width: 14px; } .close-button:active { background-image: url(chrome://theme/IDR_CLOSE_DIALOG_P); } .close-button:hover { background-image: url(chrome://theme/IDR_CLOSE_DIALOG_H); } dialog.config > .close-button { position: absolute; right: 7px; top: 7px; } dialog.config > .title { font-size: 130%; } dialog.config > .list { border: 1px solid #eee; height: 180px; margin-bottom: 10px; margin-top: 10px; overflow-x: hidden; } .config-list-row { -webkit-flex-direction: row; display: -webkit-flex; } .config-list-row:hover { background-color: #eee; } .config-list-row.selected, .config-list-row.selected:hover { background-color: #ccc; } .config-list-row input { border: 1px solid transparent; line-height: 20px; margin: 4px; min-width: 0; padding: 0 3px; } .config-list-row.fresh:not(.selected) input { border-color: #eee; } .config-list-row input.port { width: 4em; } .config-list-row input.location { -webkit-flex: 1; width: 100%; } .config-list-row:not(.empty) input.invalid { background-color: rgb(255, 200, 200); } .config-list-row .close-button { margin: 8px 8px; } .config-list-row.fresh .close-button, .config-list-row:not(.selected):not(:hover) .close-button:not(:hover) { background-image: none; pointer-events: none; } .config-list-row:not(.selected) .close-button:not(:hover) { opacity: 0.5; } dialog.config > .message { margin-bottom: 12px; width: 20em; } .config-buttons { align-items: center; display: flex; } dialog.port-forwarding .target-discovery { display: none; } dialog.target-discovery .port-forwarding { display: none; } .config-buttons > label { flex-grow: 1 } Inspect with Chrome Developer Tools
Port forwarding is active. Closing this page terminates it.
Pages
Extensions
Apps
Shared workers
Service workers
Other
Port forwarding settings
Target discovery settings
Define the listening port on your device that maps to a port accessible from your development machine. Learn more
Specify hosts and ports of the target discovery servers.
// 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. var MIN_VERSION_TAB_CLOSE = 25; var MIN_VERSION_TARGET_ID = 26; var MIN_VERSION_NEW_TAB = 29; var MIN_VERSION_TAB_ACTIVATE = 30; var WEBRTC_SERIAL = 'WEBRTC'; var queryParamsObject = {}; var browserInspector; var browserInspectorTitle; (function() { var queryParams = window.location.search; if (!queryParams) return; var params = queryParams.substring(1).split('&'); for (var i = 0; i < params.length; ++i) { var pair = params[i].split('='); queryParamsObject[pair[0]] = pair[1]; } if ('trace' in queryParamsObject || 'tracing' in queryParamsObject) { browserInspector = 'chrome://tracing'; browserInspectorTitle = 'trace'; } else { browserInspector = queryParamsObject['browser-inspector']; browserInspectorTitle = 'inspect'; } })(); function sendCommand(command, args) { chrome.send(command, Array.prototype.slice.call(arguments, 1)); } function sendTargetCommand(command, target) { sendCommand(command, target.source, target.id); } function removeChildren(element_id) { var element = $(element_id); element.textContent = ''; } function removeAdditionalChildren(element_id) { var element = $(element_id); var elements = element.querySelectorAll('.row.additional'); for (var i = 0; i != elements.length; i++) element.removeChild(elements[i]); } function removeChildrenExceptAdditional(element_id) { var element = $(element_id); var elements = element.querySelectorAll('.row:not(.additional)'); for (var i = 0; i != elements.length; i++) element.removeChild(elements[i]); } function onload() { var tabContents = document.querySelectorAll('#content > div'); for (var i = 0; i != tabContents.length; i++) { var tabContent = tabContents[i]; var tabName = tabContent.querySelector('.content-header').textContent; var tabHeader = document.createElement('div'); tabHeader.className = 'tab-header'; var button = document.createElement('button'); button.textContent = tabName; tabHeader.appendChild(button); tabHeader.addEventListener('click', selectTab.bind(null, tabContent.id)); $('navigation').appendChild(tabHeader); } onHashChange(); initSettings(); sendCommand('init-ui'); } function onHashChange() { var hash = window.location.hash.slice(1).toLowerCase(); if (!selectTab(hash)) selectTab('devices'); } /** * @param {string} id Tab id. * @return {boolean} True if successful. */ function selectTab(id) { var tabContents = document.querySelectorAll('#content > div'); var tabHeaders = $('navigation').querySelectorAll('.tab-header'); var found = false; for (var i = 0; i != tabContents.length; i++) { var tabContent = tabContents[i]; var tabHeader = tabHeaders[i]; if (tabContent.id == id) { tabContent.classList.add('selected'); tabHeader.classList.add('selected'); found = true; } else { tabContent.classList.remove('selected'); tabHeader.classList.remove('selected'); } } if (!found) return false; window.location.hash = id; return true; } function populateTargets(source, data) { if (source == 'local') populateLocalTargets(data); else if (source == 'remote') populateRemoteTargets(data); else console.error('Unknown source type: ' + source); } function populateAdditionalTargets(data) { removeAdditionalChildren('others-list'); for (var i = 0; i < data.length; i++) addAdditionalTargetsToOthersList(data[i]); } function populateLocalTargets(data) { removeChildren('pages-list'); removeChildren('extensions-list'); removeChildren('apps-list'); removeChildren('workers-list'); removeChildren('service-workers-list'); removeChildrenExceptAdditional('others-list'); for (var i = 0; i < data.length; i++) { if (data[i].type === 'page') addToPagesList(data[i]); else if (data[i].type === 'background_page') addToExtensionsList(data[i]); else if (data[i].type === 'app') addToAppsList(data[i]); else if (data[i].type === 'shared_worker') addToWorkersList(data[i]); else if (data[i].type === 'service_worker') addToServiceWorkersList(data[i]); else addToOthersList(data[i]); } } function showIncognitoWarning() { $('devices-incognito').hidden = false; } function alreadyDisplayed(element, data) { var json = JSON.stringify(data); if (element.cachedJSON == json) return true; element.cachedJSON = json; return false; } function updateBrowserVisibility(browserSection) { var icon = browserSection.querySelector('.used-for-port-forwarding'); browserSection.hidden = !browserSection.querySelector('.open') && !browserSection.querySelector('.row') && !browserInspector && (!icon || icon.hidden); } function updateUsernameVisibility(deviceSection) { var users = new Set(); var browsers = deviceSection.querySelectorAll('.browser'); Array.prototype.forEach.call(browsers, function(browserSection) { if (!browserSection.hidden) { var browserUser = browserSection.querySelector('.browser-user'); if (browserUser) users.add(browserUser.textContent); } }); var hasSingleUser = users.size <= 1; Array.prototype.forEach.call(browsers, function(browserSection) { var browserUser = browserSection.querySelector('.browser-user'); if (browserUser) browserUser.hidden = hasSingleUser; }); } function populateRemoteTargets(devices) { if (!devices) return; if ($('config-dialog').open) { window.holdDevices = devices; return; } function browserCompare(a, b) { if (a.adbBrowserName != b.adbBrowserName) return a.adbBrowserName < b.adbBrowserName; if (a.adbBrowserVersion != b.adbBrowserVersion) return a.adbBrowserVersion < b.adbBrowserVersion; return a.id < b.id; } function insertBrowser(browserList, browser) { for (var sibling = browserList.firstElementChild; sibling; sibling = sibling.nextElementSibling) { if (browserCompare(browser, sibling)) { browserList.insertBefore(browser, sibling); return; } } browserList.appendChild(browser); } var deviceList = $('devices-list'); if (alreadyDisplayed(deviceList, devices)) return; function removeObsolete(validIds, section) { if (validIds.indexOf(section.id) < 0) section.remove(); } var newDeviceIds = devices.map(function(d) { return d.id; }); Array.prototype.forEach.call( deviceList.querySelectorAll('.device'), removeObsolete.bind(null, newDeviceIds)); $('devices-help').hidden = !!devices.length; for (var d = 0; d < devices.length; d++) { var device = devices[d]; var deviceSection = $(device.id); if (!deviceSection) { deviceSection = document.createElement('div'); deviceSection.id = device.id; deviceSection.className = 'device'; deviceList.appendChild(deviceSection); var deviceHeader = document.createElement('div'); deviceHeader.className = 'device-header'; deviceSection.appendChild(deviceHeader); var deviceName = document.createElement('div'); deviceName.className = 'device-name'; deviceHeader.appendChild(deviceName); var deviceSerial = document.createElement('div'); deviceSerial.className = 'device-serial'; var serial = device.adbSerial.toUpperCase(); deviceSerial.textContent = '#' + serial; deviceHeader.appendChild(deviceSerial); if (serial === WEBRTC_SERIAL) deviceHeader.classList.add('hidden'); var devicePorts = document.createElement('div'); devicePorts.className = 'device-ports'; deviceHeader.appendChild(devicePorts); var browserList = document.createElement('div'); browserList.className = 'browsers'; deviceSection.appendChild(browserList); var authenticating = document.createElement('div'); authenticating.className = 'device-auth'; deviceSection.appendChild(authenticating); } if (alreadyDisplayed(deviceSection, device)) continue; deviceSection.querySelector('.device-name').textContent = device.adbModel; deviceSection.querySelector('.device-auth').textContent = device.adbConnected ? '' : 'Pending authentication: please accept ' + 'debugging session on the device.'; var browserList = deviceSection.querySelector('.browsers'); var newBrowserIds = device.browsers.map(function(b) { return b.id; }); Array.prototype.forEach.call( browserList.querySelectorAll('.browser'), removeObsolete.bind(null, newBrowserIds)); for (var b = 0; b < device.browsers.length; b++) { var browser = device.browsers[b]; var majorChromeVersion = browser.adbBrowserChromeVersion; var pageList; var browserSection = $(browser.id); if (browserSection) { pageList = browserSection.querySelector('.pages'); } else { browserSection = document.createElement('div'); browserSection.id = browser.id; browserSection.className = 'browser'; insertBrowser(browserList, browserSection); var browserHeader = document.createElement('div'); browserHeader.className = 'browser-header'; var browserName = document.createElement('div'); browserName.className = 'browser-name'; browserHeader.appendChild(browserName); browserName.textContent = browser.adbBrowserName; if (browser.adbBrowserVersion) browserName.textContent += ' (' + browser.adbBrowserVersion + ')'; if (browser.adbBrowserUser) { var browserUser = document.createElement('div'); browserUser.className = 'browser-user'; browserUser.textContent = browser.adbBrowserUser; browserHeader.appendChild(browserUser); } browserSection.appendChild(browserHeader); if (majorChromeVersion >= MIN_VERSION_NEW_TAB) { var newPage = document.createElement('div'); newPage.className = 'open'; var newPageUrl = document.createElement('input'); newPageUrl.type = 'text'; newPageUrl.placeholder = 'Open tab with url'; newPage.appendChild(newPageUrl); var openHandler = function(sourceId, browserId, input) { sendCommand( 'open', sourceId, browserId, input.value || 'about:blank'); input.value = ''; }.bind(null, browser.source, browser.id, newPageUrl); newPageUrl.addEventListener('keyup', function(handler, event) { if (event.key == 'Enter' && event.target.value) handler(); }.bind(null, openHandler), true); var newPageButton = document.createElement('button'); newPageButton.textContent = 'Open'; newPage.appendChild(newPageButton); newPageButton.addEventListener('click', openHandler, true); browserHeader.appendChild(newPage); } var portForwardingInfo = document.createElement('div'); portForwardingInfo.className = 'used-for-port-forwarding'; portForwardingInfo.hidden = true; portForwardingInfo.title = 'This browser is used for port ' + 'forwarding. Closing it will drop current connections.'; browserHeader.appendChild(portForwardingInfo); if (browserInspector) { var link = document.createElement('span'); link.classList.add('action'); link.setAttribute('tabindex', 1); link.textContent = browserInspectorTitle; browserHeader.appendChild(link); link.addEventListener( 'click', sendCommand.bind( null, 'inspect-browser', browser.source, browser.id, browserInspector), false); } pageList = document.createElement('div'); pageList.className = 'list pages'; browserSection.appendChild(pageList); } if (!alreadyDisplayed(browserSection, browser)) { pageList.textContent = ''; for (var p = 0; p < browser.pages.length; p++) { var page = browser.pages[p]; // Attached targets have no unique id until Chrome 26. For such // targets it is impossible to activate existing DevTools window. page.hasNoUniqueId = page.attached && majorChromeVersion && majorChromeVersion < MIN_VERSION_TARGET_ID; var row = addTargetToList(page, pageList, ['name', 'url']); if (page['description']) addWebViewDetails(row, page); else addFavicon(row, page); if (majorChromeVersion >= MIN_VERSION_TAB_ACTIVATE) { addActionLink( row, 'focus tab', sendTargetCommand.bind(null, 'activate', page), false); } if (majorChromeVersion) { addActionLink( row, 'reload', sendTargetCommand.bind(null, 'reload', page), page.attached); } if (majorChromeVersion >= MIN_VERSION_TAB_CLOSE) { addActionLink( row, 'close', sendTargetCommand.bind(null, 'close', page), false); } } } updateBrowserVisibility(browserSection); } updateUsernameVisibility(deviceSection); } } function addToPagesList(data) { var row = addTargetToList(data, $('pages-list'), ['name', 'url']); addFavicon(row, data); if (data.guests) addGuestViews(row, data.guests); } function addToExtensionsList(data) { var row = addTargetToList(data, $('extensions-list'), ['name', 'url']); addFavicon(row, data); if (data.guests) addGuestViews(row, data.guests); } function addToAppsList(data) { var row = addTargetToList(data, $('apps-list'), ['name', 'url']); addFavicon(row, data); if (data.guests) addGuestViews(row, data.guests); } function addGuestViews(row, guests) { Array.prototype.forEach.call(guests, function(guest) { var guestRow = addTargetToList(guest, row, ['name', 'url']); guestRow.classList.add('guest'); addFavicon(guestRow, guest); }); } function addToWorkersList(data) { var row = addTargetToList(data, $('workers-list'), ['name', 'description', 'url']); addActionLink( row, 'terminate', sendTargetCommand.bind(null, 'close', data), false); } function addToServiceWorkersList(data) { var row = addTargetToList( data, $('service-workers-list'), ['name', 'description', 'url']); addActionLink( row, 'terminate', sendTargetCommand.bind(null, 'close', data), false); } function addToOthersList(data) { addTargetToList(data, $('others-list'), ['url']); } function addAdditionalTargetsToOthersList(data) { addTargetToList(data, $('others-list'), ['name', 'url']); } function formatValue(data, property) { var value = data[property]; if (property == 'name' && value == '') { value = 'untitled'; } var text = value ? String(value) : ''; if (text.length > 100) text = text.substring(0, 100) + '\u2026'; var div = document.createElement('div'); div.textContent = text; div.className = property; return div; } function addFavicon(row, data) { var favicon = document.createElement('img'); if (data['faviconUrl']) favicon.src = data['faviconUrl']; var propertiesBox = row.querySelector('.properties-box'); propertiesBox.insertBefore(favicon, propertiesBox.firstChild); } function addWebViewDetails(row, data) { var webview; try { webview = JSON.parse(data['description']); } catch (e) { return; } addWebViewDescription(row, webview); if (data.adbScreenWidth && data.adbScreenHeight) addWebViewThumbnail( row, webview, data.adbScreenWidth, data.adbScreenHeight); } function addWebViewDescription(row, webview) { var viewStatus = {visibility: '', position: '', size: ''}; if (!webview.empty) { if (webview.attached && !webview.visible) viewStatus.visibility = 'hidden'; else if (!webview.attached) viewStatus.visibility = 'detached'; viewStatus.size = 'size ' + webview.width + ' \u00d7 ' + webview.height; } else { viewStatus.visibility = 'empty'; } if (webview.attached) { viewStatus.position = 'at (' + webview.screenX + ', ' + webview.screenY + ')'; } var subRow = document.createElement('div'); subRow.className = 'subrow webview'; if (webview.empty || !webview.attached || !webview.visible) subRow.className += ' invisible-view'; if (viewStatus.visibility) subRow.appendChild(formatValue(viewStatus, 'visibility')); if (viewStatus.position) subRow.appendChild(formatValue(viewStatus, 'position')); subRow.appendChild(formatValue(viewStatus, 'size')); var subrowBox = row.querySelector('.subrow-box'); subrowBox.insertBefore(subRow, row.querySelector('.actions')); } function addWebViewThumbnail(row, webview, screenWidth, screenHeight) { var maxScreenRectSize = 50; var screenRectWidth; var screenRectHeight; var aspectRatio = screenWidth / screenHeight; if (aspectRatio < 1) { screenRectWidth = Math.round(maxScreenRectSize * aspectRatio); screenRectHeight = maxScreenRectSize; } else { screenRectWidth = maxScreenRectSize; screenRectHeight = Math.round(maxScreenRectSize / aspectRatio); } var thumbnail = document.createElement('div'); thumbnail.className = 'webview-thumbnail'; var thumbnailWidth = 3 * screenRectWidth; var thumbnailHeight = 60; thumbnail.style.width = thumbnailWidth + 'px'; thumbnail.style.height = thumbnailHeight + 'px'; var screenRect = document.createElement('div'); screenRect.className = 'screen-rect'; screenRect.style.left = screenRectWidth + 'px'; screenRect.style.top = (thumbnailHeight - screenRectHeight) / 2 + 'px'; screenRect.style.width = screenRectWidth + 'px'; screenRect.style.height = screenRectHeight + 'px'; thumbnail.appendChild(screenRect); if (!webview.empty && webview.attached) { var viewRect = document.createElement('div'); viewRect.className = 'view-rect'; if (!webview.visible) viewRect.classList.add('hidden'); function percent(ratio) { return ratio * 100 + '%'; } viewRect.style.left = percent(webview.screenX / screenWidth); viewRect.style.top = percent(webview.screenY / screenHeight); viewRect.style.width = percent(webview.width / screenWidth); viewRect.style.height = percent(webview.height / screenHeight); screenRect.appendChild(viewRect); } var propertiesBox = row.querySelector('.properties-box'); propertiesBox.insertBefore(thumbnail, propertiesBox.firstChild); } function addTargetToList(data, list, properties) { var row = document.createElement('div'); row.className = 'row'; row.targetId = data.id; var propertiesBox = document.createElement('div'); propertiesBox.className = 'properties-box'; row.appendChild(propertiesBox); var subrowBox = document.createElement('div'); subrowBox.className = 'subrow-box'; propertiesBox.appendChild(subrowBox); var subrow = document.createElement('div'); subrow.className = 'subrow'; subrowBox.appendChild(subrow); for (var j = 0; j < properties.length; j++) subrow.appendChild(formatValue(data, properties[j])); var actionBox = document.createElement('div'); actionBox.className = 'actions'; subrowBox.appendChild(actionBox); if (data.isAdditional) { addActionLink( row, 'inspect', sendCommand.bind(null, 'inspect-additional', data.url), false); row.classList.add('additional'); } else if (!data.hasCustomInspectAction) { addActionLink( row, 'inspect', sendTargetCommand.bind(null, 'inspect', data), data.hasNoUniqueId || data.adbAttachedForeign); } list.appendChild(row); return row; } function addActionLink(row, text, handler, opt_disabled) { var link = document.createElement('span'); link.classList.add('action'); link.setAttribute('tabindex', 1); if (opt_disabled) link.classList.add('disabled'); else link.classList.remove('disabled'); link.textContent = text; link.addEventListener('click', handler, true); function handleKey(e) { if (e.key == 'Enter' || e.key == ' ') { e.preventDefault(); handler(); } } link.addEventListener('keydown', handleKey, true); row.querySelector('.actions').appendChild(link); } function initSettings() { checkboxSendsCommand( 'discover-usb-devices-enable', 'set-discover-usb-devices-enabled'); checkboxSendsCommand('port-forwarding-enable', 'set-port-forwarding-enabled'); checkboxSendsCommand( 'discover-tcp-devices-enable', 'set-discover-tcp-targets-enabled'); $('port-forwarding-config-open') .addEventListener('click', openPortForwardingConfig); $('tcp-discovery-config-open').addEventListener('click', openTargetsConfig); $('config-dialog-close').addEventListener('click', function() { $('config-dialog').commit(true); }); $('node-frontend') .addEventListener('click', sendCommand.bind(null, 'open-node-frontend')); } function checkboxHandler(command, event) { sendCommand(command, event.target.checked); } function checkboxSendsCommand(id, command) { $(id).addEventListener('change', checkboxHandler.bind(null, command)); } function handleKey(event) { switch (event.keyCode) { case 13: // Enter var dialog = $('config-dialog'); if (event.target.nodeName == 'INPUT') { var line = event.target.parentNode; if (!line.classList.contains('fresh') || line.classList.contains('empty')) { dialog.commit(true); } else { commitFreshLineIfValid(true /* select new line */); dialog.commit(false); } } else { dialog.commit(true); } break; } } function commitDialog(commitHandler, shouldClose) { var element = $('config-dialog'); if (element.open && shouldClose) { element.onclose = null; element.close(); document.removeEventListener('keyup', handleKey); if (window.holdDevices) { populateRemoteTargets(window.holdDevices); delete window.holdDevices; } } commitFreshLineIfValid(); commitHandler(); } function openConfigDialog(dialogClass, commitHandler, lineFactory, data) { var dialog = $('config-dialog'); if (dialog.open) return; dialog.className = dialogClass; dialog.classList.add('config'); document.addEventListener('keyup', handleKey); dialog.commit = commitDialog.bind(null, commitHandler); dialog.onclose = commitDialog.bind(null, commitHandler, true); $('button-done').onclick = dialog.onclose; var list = $('config-dialog').querySelector('.list'); list.textContent = ''; list.createRow = appendRow.bind(null, list, lineFactory); for (var key in data) list.createRow(key, data[key]); list.createRow(null, null); dialog.showModal(); var defaultFocus = dialog.querySelector('.fresh .preselected'); if (defaultFocus) defaultFocus.focus(); else doneButton.focus(); } function openPortForwardingConfig() { function createPortForwardingConfigLine(port, location) { var line = document.createElement('div'); line.className = 'port-forwarding-pair config-list-row'; var portInput = createConfigField(port, 'port preselected', 'Port', validatePort); line.appendChild(portInput); var locationInput = createConfigField( location, 'location', 'IP address and port', validateLocation); locationInput.classList.add('primary'); line.appendChild(locationInput); return line; } function commitPortForwardingConfig() { var config = {}; filterList(['.port', '.location'], function(port, location) { config[port] = location; }); sendCommand('set-port-forwarding-config', config); } openConfigDialog( 'port-forwarding', commitPortForwardingConfig, createPortForwardingConfigLine, window.portForwardingConfig); } function openTargetsConfig() { function createTargetDiscoveryConfigLine(index, targetDiscovery) { var line = document.createElement('div'); line.className = 'target-discovery-line config-list-row'; var locationInput = createConfigField( targetDiscovery, 'location preselected', 'IP address and port', validateLocation); locationInput.classList.add('primary'); line.appendChild(locationInput); return line; } function commitTargetDiscoveryConfig() { var entries = []; filterList(['.location'], function(location) { entries.push(location); }); sendCommand('set-tcp-discovery-config', entries); } openConfigDialog( 'target-discovery', commitTargetDiscoveryConfig, createTargetDiscoveryConfigLine, window.targetDiscoveryConfig); } function filterList(fieldSelectors, callback) { var lines = $('config-dialog').querySelectorAll('.config-list-row'); for (var i = 0; i != lines.length; i++) { var line = lines[i]; var values = []; for (var selector of fieldSelectors) { var input = line.querySelector(selector); var value = input.classList.contains('invalid') ? input.lastValidValue : input.value; if (!value) break; values.push(value); } if (values.length == fieldSelectors.length) callback.apply(null, values); } } function updateCheckbox(id, enabled) { var checkbox = $(id); checkbox.checked = !!enabled; checkbox.disabled = false; } function updateDiscoverUsbDevicesEnabled(enabled) { updateCheckbox('discover-usb-devices-enable', enabled); } function updatePortForwardingEnabled(enabled) { updateCheckbox('port-forwarding-enable', enabled); $('infobar').classList.toggle('show', enabled); $('infobar').scrollIntoView(); } function updatePortForwardingConfig(config) { window.portForwardingConfig = config; $('port-forwarding-config-open').disabled = !config; } function updateTCPDiscoveryEnabled(enabled) { updateCheckbox('discover-tcp-devices-enable', enabled); } function updateTCPDiscoveryConfig(config) { window.targetDiscoveryConfig = config; $('tcp-discovery-config-open').disabled = !config; } function appendRow(list, lineFactory, key, value) { var line = lineFactory(key, value); line.lastElementChild.addEventListener('keydown', function(e) { if (e.key == 'Tab' && !hasKeyModifiers(e) && line.classList.contains('fresh') && !line.classList.contains('empty')) { // Tabbing forward on the fresh line, try create a new empty one. if (commitFreshLineIfValid(true)) e.preventDefault(); } }); var lineDelete = document.createElement('div'); lineDelete.className = 'close-button'; lineDelete.addEventListener('click', function() { var newSelection = line.nextElementSibling || line.previousElementSibling; selectLine(newSelection, true); line.parentNode.removeChild(line); $('config-dialog').commit(false); }); line.appendChild(lineDelete); line.addEventListener('click', selectLine.bind(null, line, true)); line.addEventListener('focus', selectLine.bind(null, line, true)); checkEmptyLine(line); if (!key && !value) line.classList.add('fresh'); return list.appendChild(line); } function validatePort(input) { var match = input.value.match(/^(\d+)$/); if (!match) return false; var port = parseInt(match[1]); if (port < 1024 || 65535 < port) return false; var inputs = document.querySelectorAll('input.port:not(.invalid)'); for (var i = 0; i != inputs.length; ++i) { if (inputs[i] == input) break; if (parseInt(inputs[i].value) == port) return false; } return true; } function validateLocation(input) { var match = input.value.match(/^([a-zA-Z0-9\.\-_]+):(\d+)$/); if (!match) return false; var port = parseInt(match[2]); return port <= 65535; } function createConfigField(value, className, hint, validate) { var input = document.createElement('input'); input.className = className; input.type = 'text'; input.placeholder = hint; input.value = value || ''; input.lastValidValue = value || ''; function checkInput() { if (validate(input)) input.classList.remove('invalid'); else input.classList.add('invalid'); if (input.parentNode) checkEmptyLine(input.parentNode); } checkInput(); input.addEventListener('keyup', checkInput); input.addEventListener('focus', function() { selectLine(input.parentNode); }); input.addEventListener('blur', function() { if (validate(input)) input.lastValidValue = input.value; }); return input; } function checkEmptyLine(line) { var inputs = line.querySelectorAll('input'); var empty = true; for (var i = 0; i != inputs.length; i++) { if (inputs[i].value != '') empty = false; } if (empty) line.classList.add('empty'); else line.classList.remove('empty'); } function selectLine(line, opt_focusInput) { if (line.classList.contains('selected')) return; var selected = line.parentElement && line.parentElement.querySelector('.selected'); if (selected) selected.classList.remove('selected'); line.classList.add('selected'); if (opt_focusInput) { var el = line.querySelector('.preselected'); if (el) { line.firstChild.select(); line.firstChild.focus(); } } } function commitFreshLineIfValid(opt_selectNew) { var line = $('config-dialog').querySelector('.config-list-row.fresh'); if (line.querySelector('.invalid')) return false; line.classList.remove('fresh'); var freshLine = line.parentElement.createRow(); if (opt_selectNew) freshLine.querySelector('.preselected').focus(); return true; } function populatePortStatus(devicesStatusMap) { for (var deviceId in devicesStatusMap) { if (!devicesStatusMap.hasOwnProperty(deviceId)) continue; var deviceStatus = devicesStatusMap[deviceId]; var deviceStatusMap = deviceStatus.ports; var deviceSection = $(deviceId); if (!deviceSection) continue; var devicePorts = deviceSection.querySelector('.device-ports'); if (alreadyDisplayed(devicePorts, deviceStatus)) continue; devicePorts.textContent = ''; for (var port in deviceStatusMap) { if (!deviceStatusMap.hasOwnProperty(port)) continue; var status = deviceStatusMap[port]; var portIcon = document.createElement('div'); portIcon.className = 'port-icon'; // status === 0 is the default (connected) state. if (status === -1 || status === -2) portIcon.classList.add('transient'); else if (status < 0) portIcon.classList.add('error'); devicePorts.appendChild(portIcon); var portNumber = document.createElement('div'); portNumber.className = 'port-number'; portNumber.textContent = ':' + port; devicePorts.appendChild(portNumber); } function updatePortForwardingInfo(browserSection) { var icon = browserSection.querySelector('.used-for-port-forwarding'); if (icon) icon.hidden = (browserSection.id !== deviceStatus.browserId); updateBrowserVisibility(browserSection); } Array.prototype.forEach.call( deviceSection.querySelectorAll('.browser'), updatePortForwardingInfo); updateUsernameVisibility(deviceSection); } function clearBrowserPorts(browserSection) { var icon = browserSection.querySelector('.used-for-port-forwarding'); if (icon) icon.hidden = true; updateBrowserVisibility(browserSection); } function clearPorts(deviceSection) { if (deviceSection.id in devicesStatusMap) return; var devicePorts = deviceSection.querySelector('.device-ports'); devicePorts.textContent = ''; delete devicePorts.cachedJSON; Array.prototype.forEach.call( deviceSection.querySelectorAll('.browser'), clearBrowserPorts); } Array.prototype.forEach.call( document.querySelectorAll('.device'), clearPorts); } document.addEventListener('DOMContentLoaded', onload); window.addEventListener('hashchange', onHashChange); ioܸ;":l[' E$Έ1GTIDQdIX}?:ӿq2c'+1o#.OZewDadXH֣Rm);Qr_pF("9TeфDќ*Y$z6VU\Tqؔ22o9fr5-L-"YLYN&H^9I]&hi|+RQv$^1)/V;*^#u zx(6Sh>>e]iQiCoJq! cIkұF, 2*DBJnHLR?"޼ [ 0r@SV(9eB9A_]Fʈ399yo Ң QDOߑ/\>=Eo)tBWD?.~'%|$jr'%#Df g\,Nѫh>?{D1g>ayx W!NSaU>axd4w2СDUĥR<71NnB;êgSO N$pJK8ֆϨ"u^.PAS}PTaMWaFqIP$kUNto-QYD$X:'`(a9i 4g&[BKL[Q0vx0E׿-#p` IB3b `V{6")IH1 786M kumAv0 QrFS$)ӶhҼ(cA: p :6uL3Hpu^>I)Ђ*njAAwiҮ8MMUG>Gkb,J2gl̰jcqjwTҘ2V~` /YQG'^ĐVYYGf3!GV|b2b.QjL.Qp76BIF[;T3H7Ƞ>uW#J@KJjWS':~'W.ǘv }[uuȞ7#G`E,H@@ 甶b V0jj@r]yB~:6$&@Qa˟"4~!hL m>l z'^_kS0ᑰ[H=Mcex8Xz=pMveB C/uEE1~CcMHƀ@ПwDe\xQ'|SGϒjx׀|R0Mׇ629:G{L]ɆFJ?AQ6 bെIA%ׁ p؍>:/%D3`o IgΩMt}-AD-I6b-}foRfȫV# d*9,8ިsશ7z6"e;b_#JגH$Y*92ͩ NߛYTwzűH PiWIr⨒u`+ Ƚ~7ryXaYh$@,$Q5 g6%d AT,PUt'/G#"  Ƌ7WǓP߄IĄmZ!^ٽĨtWRS~6[ҁBi[uYև5g`-* xsc^X( f)P}LrBP(w1PFǒY/n Q]D +1ʼbMr'h-hEMூȨ ' ,, T32(メ8y"M$QICٿLpQ 5+HXi`е$b *'ժjY$2eΦ8/}a)0)CuzF*xp{.6_]/P#hKC$\5{*kgLI@q&7 ;v"]KY egɣ|hĕ= V9vicvo9UQ#ЦE\)T?a=cb6U 4=+|$MĘєUQȲ9 b6kc} X>":]򧁎,aA6>9{ܼ@W5ľG+}w X GæDAhN fJL0MU xJg&\c}ߕnLbQ᠎< b bbYî(di<3Oq aXׄ@$Mu!X9!RJ/ۻ`)g܃gFomAh5)[,3yWy2᫞ ֩w Dxm0AoES'̊MLhd+. s:!6Fn"\) 9@* PG.B*B0mu@V5@M-b43)ͯ<#=m+>xG`vA Ks4_N5HVю)0 R C*<0vqpwelvZ\ 9#XeOc+;c%98kuL80P(6Qȋ:k72^'Tm܅|z&qcb+f _UNLJ)Ϸ ij^t"`yN:O\|^Iu2.8?;;ʏģ΁sk|CCN`;"2AO8G¨F,RYk]2"ԋ:W,KL: N76.\* ֙`Fh"Ìp,lSuDx Sty2kӼgE?J$J1k>xgoZW:Sے8lQ CkVbyVZ]v2J&}:mPi*pTT("!s /et>,_pD/bHεX#lwrj<2*@75A1z(NO1_wөkg5 "T^Frx&Ad'Bq+2aVLEq!'J9GZ*8)A7ȸ#iMOO[y& /JnW]CۨmDž'?'FgQ Tm5-mXJ}m)%sN܂uB;4X%QEf3@>3 pC'Ԏ(SvDppfWz ]8cXDد5+b0 VjU]G@݀* 7ҡr ^<"~X@}NL| l6j*#v#Ҏ}'r tYV9u>>*2Y3^3rX: d9L%l!Ա 'lD G|lp~*9@w&$Q_.P]p47~:!q|*6UY R~3h_.ٺF~I-Aݦ(32㓘EuGث>DE & ɘSlԃYG>b(-:Y}BaL+!Qg0n(* Uo.& v'3 !e)ǐ$!W=BzMTbWm8CN+0.i_ߺRJFOQ^Ru?]FeCi5ןd )x1 .@%Sɒ"M$p.:7h5·j%ńό5tKgL͛u%i[qIЄ}YrN* Gߚ]@: !:dBK7c!҇1ݛ"mxtjH}F(&R/4ߥh|+mN =h3+q_SSnoOƓ>4E j<_VXްF3SuEB!X0 jxfڙWhxႍd˒B 9ӣZF.+yi2kRZtG__͡ t\(7|0&iqt3OI˰u Da`UzQ WE^t`/30Gw!I jcR]{Dnw)gvһjy,ʇfыvILV>(p3v=s5Egk;_h18{4s:zpwS#lj iLlu?q3a^i"0Q_+s+ vyvj'HL1c\QU}+PC;\muMB6]߅|௡Nܳ%BwqeZ5J)ϊLՠؕ岊u yvZ60BFٓ(nb(<[u` G8 {m#OxnT*a VEyT } ηr5flgYB`Pk˜9|[imlQcMOۧZs`DJ"B[Ðm!vjݑ_}g hנQtLo*Pw7->cKKӇ@ύ8 r?p/ Sr$B5ߢ+nhQI3agGoLxˊ;8K#X`5׌ÈgYQڡj |av2Re+쨊[RҿÆԽxC_2}luwQ'(gLAή$\^6h?7j:>6Fӣ>Q&w p؀ jT%ou RϗwU'xVM҅#?; Qm^>|v8CNz5| BDEQr\/HHH$ 9/ό(z{'`w~Ƿ!Q z_moķ^p MQ' 7'wo\Ä4o//>\C<_mq(F8:%<^8=_"p~/' lpO/ }J>]$$ɐxS~wE?oCw'x*(<(pNCJO:{3os9cE.zr8qIl> #3:;\{<}x9xV0cXfЋU%pHoAoH G~0CaO3( +6FcV@0}qx>$7F[񽟘d!&425 C$LF>G֟F(A?QR^֐{r2hB}f^O1q_{ϴ&ARQmEoਓ\nEu{aC؃g+1M*Ţ؎D;E.8-N' g%M ɔqfx=w=kMŒ}S+5gVϹkޘ;t"2TD=s`߁OCp"EN.fNoq=ONV+8 gVGŐ-nNJz 7od,P?.nFLU˱JW+jMO4 XG߽i qw UN d=ՕD,YF˒ҴwW k'""]Uok8; +IWi0l:V.ͫPV'Y(.:C?S6%3HU`r9G*y Frm:':߀P9Z3P)&5ƒ$Qa_cN`<|D>~ Bb@LX]0`QFc !4ƕtćb4t"ӑ*n za&l#+/U/pBUAW/].On0!/s|A ޻Ƚ/XNV6tqqcDCtXABg6(N൤#FF3Ry2/~DcDrϾ"cZNMlǶ5+PnVZB}2+ImX_}0\SLZ]l08r0B.tQK:zm",+fҁjrC ]ϏV"W0n>^=0*q7ekIjnC^TKzr.Jy{ ck3r&s2]HLlIY=܂\\^"b-6>FNh0`G)|Fo-Sr5%iBt9 ro_2*͡F2w,4B7A *},ef=|֋oZ"Ҵ5&kڶ)\S #[2:IkK9IE2}z\G:IH99J:/QKA//^rl1{m )[_jEzڲ703ͨ T֫m0R+K!Sj)4(^B.qWnRj`4wFG{G&DxSR;{GȐT8?!'Q24R&$zj@ Kffqn]y`N ɨN0 QƏ5sGOc@$//J7l̦fp z|kfU%b^=G8HOJbo`sF4"##FD䌰r=|s/ɲ7-Cw#a~X wL?gXB%ȵ'ѭ5Mnp &y;OM{N>co20JBQ4O<+sG<#s*d`렂Ͻ؍ǷXN:f5.K|4M 3: @-'4X)h3GXvzm%V 4`l2=d$fVD[oX(LT~TYv4&H~ 27\tpyݘ5=~iy#/]E]+(h6:Y Č+:61:WqMscM 5|ƍ\(W?%,Bv$w宨4rzV$xLĐhjl"^,EVqΙzj R(u:xs3@_x_ @al ]Sc7@ԉa^b=*p)qE@s( Ӊ_E֋.P2( sk!>6; T["&˫zK:M=(,]r%d+je_T~&Dru WH(EKa_ރӪǺYm+Tij-Xl2[|7ŠWʤ&\~KԶԫo%쮾94l~MV Gم|wH>hօ;K'ر-Apđw+ 7"w d* p +ɌL EgKZU2\FhiTxvKQ[\Ʀ@/ݐؿ_䃈CwCg姑 ;9FE([1w }kl3Di+g Ҁ,غ?''/W%Bh=zs(n1k~vO[H&cص@*ӉXsD*lhp_1px@NL|_htk??F#M:orwcMZo0GDŽx_&Uݽoa۾F-Я5=*ɻgdK;GJ 'RK+0!] djP^*Y|ൣ d;Rq}TG+eǷcj|E~cjenZb׵"b%Җm љh):Zh ev"u+|jjr:dx@l}kh" KFXF)8r DL|œ-}yo G&Mzbv,;' ՖhxXicmIkU,ʗ-3+3k5O8"h{6O}34q^hs{X}'G#MK/h#XK颾`e8izUq:V T1yG8Pئ!Q_jv4|ug]Qml}VVs;eo=hjEiNE/ԟbU1KBWױ&/mX rxyl%a2+←7Dt I (xnM޹kq/V{ūtǒEi%Tr/I,\Z]_v D/9L@gf;Moj;Q5vm]/۳l!5n!_P%-GCo=Λm(]t :#wUJWF{苜NjCX[o6~MȖMdXCn0i %j$ewx]C"y>;qI(&>S1,շggͭ l>RXdf@gD'XL<۷@'w@D+\ܶ#U߳O')Am"?G)RVU!Y"$UFÿ*)D!c V@6%(: :b38Q3Qi# :E?{a9$7`Sk5 V8S/+0D ~%ۼ,(#Rs5DF_N>M&SmJ+,\5jy^i"YLܲ,"_'+Ԡ jj 0/٤_7o e^8TD"Ҁ^aw$$x'By ӴgSα\PhrL˶_Mt9 sJh,$6 -yNeG8XG1gy$$WX lz"< ϵy)ۦm }qFNӁ 8=I9M!bG0s|̞/HvyJIPE|?r }3.mIexwFtw! ÓeyЇޚp9qDyC֋HXy[ ƶR+R4)Cɗ \\I!vt<KŘ;) -R`)%,ñULsBH#" ؑC&[CH5$j t/)^6TaG ''j7쁒coJ=t^. 2:ucBІ\g: qr!yJ)WE˜MFa}1am-^. rLM IkeOB%Mz RSH>V}lα3/8{HR|.(XAQgjЀ3`Up~A;{Y}o6vXkj#܂Zf)146jzyǀe>BubFVԳI8,] &oԼ [aGvM-\ Ǒ7_`ZFkʂ#6hہ@Y}/_U`,ΐڿNvJѲYds/`ԻjP PH#(5AfuU9M5zqu}iU:w p50]6W 9껔/R FylAwe?;B+3eD(}D67Hl[X]vPi*;@yD%1A}:y:뚏QM5F.;P#Ĉ&S!)ퟫ2ܽQXJkJ{BeY*22iKWHwݳmV 5Zub|( F|yUk더B6XF/ є*͉t PW"?..0bD{3o3i>MAv(X۳?28WT"7Нq&^NKl z6cMVh'Ƭ)ɼPPORȊ,یiRШ9g{XmO9_1MUMq!DUDtB=5ݬWHOoM6Kޠp}f~f ِH)٘Y,:MS2uǧקj%=bz(.=sG,MQ{ĀC\%JwA6w~GpJ VH'Rx\km>*@3!sCsMm`$1!$qv' mz y;M͉T/`+&{wUwooX`2I4UeŁbɁOu5Xэ66LZ=r~<&~L):V콐EJ|qԍ\9چmo·01| 2S1L=5B 7ԵɫOR%SHcVׯѻ.c:3W…jė c)hT pg)i0+]h}3:,β[,q>ܞ 3b:}RT".i6e9lJ+[B {rN2h*+K07@4%ͺ}Gh\ Jπi WHGUkJ[0]8;ZDRwy%rfTMY0߉#QϢ{aƆ:uxV\YA.|{gߦԨh[Zt̎V3t/pEk9tMuaV.IcOΠ&?XYoF~ׯE$;-DuS_@8|A ,ɑewV3˥DRLzH,~s_ކϴH, x%VYL`&4Gh@A}qކs`iBG녺Ba >0v"2ŒDXD!2XY 2sG/O`,S z|vsphH& 1NBÌ޸"+UFG$O➻y1U0l@jc`d_]cػw Ҕe(%Tڤ)p2%")Sd EBYh "¢8b^lD8E +g$Y.R Ʃ$+91nIT4 qgDSRAG֤7$9Z*r⎫=U=2C1Š,s{pDWBs8Y԰ ?gҔ˜<+E.>h`8]θNx7-`T4Aur% ξgel0I(bcnJ{gqKxv< q,5>;A}Q Bԑ7\j &A*s/ym{{P7 !n &C_V)Ԅgus!z|nF RM@Ua,Ԍ~ Djʘ**&D(E6[_4RaKAyן&6/F~™ٍB9&“6`?k|~ܾQ+$2ӫj"GL2r R$0hr]O1ݡ/<>a;꬈^R /(8(pwDl$ 1(e}Wڈ8>ǔTzᏕ1o5]qM5'XG&c`#COag0C)J*x7%,løk~a`=pI^=V2Bu}.DS*O~wvO܅)sw=w~ԩsPqwJ{l*:Gʸ]ZN\m*7Qyj[\~L9!iYڨ1"GUѴ3\ TNAPv6QߨOz0 Xh)/j2a/UK,(I5׳zpŇDDcehJ놓cqLcR[rUqA4q򭦆9.0h,%`XX!6\}#B1jo=Qg"WoSs $2
$2
$1
$1
$2
$2
$2
$3
$i18n{devicesTitle}

$i18n{devicesTitle}

$i18n{availableDevicesTitle}

$i18n{noPrintersOnNetworkExplanation}

$i18n{titleConnector}

$i18n{myDevicesTitle}

/* 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. */ body { margin: 21px 10px 24px 10px; } h1 { margin: 0 0 13px 0; } h2 { margin: 23px 0 0 0; } header { border-bottom: 1px solid #eee; max-width: 718px; } .device { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAAAAADFHGIkAAAAO0lEQVR4AWP4jwNAJBajIoRENioiLJGNCqASi7MxwWK8OggblUXYKMpdlUM9o7KpbxTJEUUwzjEAAQkAYJOdbPA0kn4AAAAASUVORK5CYII=) no-repeat; margin: 23px 0; max-width: 695px; overflow: hidden; } html[dir='rtl'] .device { background-position: right top; } .device .device-info { -webkit-padding-start: 40px; float: left; } .printer { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAAAsklEQVR4Xr3UvQ2DMBSF0StKpmCBLMAsVIyBWCCKBEpIjDH5KVJlSuIiusL2syMadDo/fY39ZCwb7RzgLosHHY6CLh70OAn6eHCWBQEdZEGAHA3efzTIGWQtDC5RA5RlspYBZgwJY1mXNUbMDKBxTdBFVVTQ0OvglqDwspQcaAFnDDBB/Uz+lTozBoaBWUDSjLdETiDMnBfmZnobvH7xcAhXer0f1sdjz+LBU7Z5W/f9BL71COOFW+QSiQAAAABJRU5ErkJggg==) no-repeat; } html[dir='rtl'] .device .device-info { float: right; } .device button { float: right; } html[dir='rtl'] .device button { float: left; } .subline, .device-subline { color: #999; margin: 5px 0; } h3.device-name { margin: 0; } .register-page { padding: 15px; width: 600px; } .register-page .button-list { padding-top: 15px; text-align: right; } html[dir='rtl'] .register-page .button-list { text-align: left; } .controls { border-bottom: 1px solid #eee; max-width: 711px; } html[dir='rtl'] .controls { padding: 13px 4px 7px 3px; } .controls .subline { -webkit-margin-start: 10px; } .login-promo { padding-bottom: 5px; padding-top: 5px; } .inline-login-promo { display: inline; } .inline-spinner { position: relative; top: 3px; } .cloud-print-message { margin: 23px 0; } section { margin-bottom: 23px; } .dialog-contents { padding-left: 17px; } #back-link { background: -webkit-image-set( url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAMCAYAAABfnvydAAAAOklEQVR4AWP4//8/Bo6b/q0DiKcDMSMuyf9QHIFTEmYCXkmQOF5JEMYnSVgBUVYQ5Uh8iggHFMGgBgBIGQYQz0k6FwAAAABJRU5ErkJggg==) 1x, url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAYCAMAAADEfo0+AAAAD1BMVEX///9el/Zel/Zel/Zel/YLJ0C2AAAABHRSTlMAiJcBFbIA6QAAAC1JREFUeAFjQAKMTMwMKHwWFogIgg8UQeEjBBB8ZrrzMQQGlwhGEGIGMkY0AACupwIderhj7AAAAABJRU5ErkJggg==) 2x) no-repeat; margin-bottom: 25px; margin-top: 6px; padding-left: 23px; } html[dir='rtl'] #back-link { transform: scaleX(-1); } html[dir='rtl'] #back-link span { display: inline-block; transform: scaleX(-1); } // 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. /** * Javascript for local_discovery.html, served from chrome://devices/ * This is used to show discoverable devices near the user as well as * cloud devices registered to them. * * The object defined in this javascript file listens for callbacks from the * C++ code saying that a new device is available as well as manages the UI for * registering a device on the local network. */ cr.define('local_discovery', function() { 'use strict'; // Histogram buckets for UMA tracking. /** @const */ var DEVICES_PAGE_EVENTS = { OPENED: 0, LOG_IN_STARTED_FROM_REGISTER_PROMO: 1, LOG_IN_STARTED_FROM_DEVICE_LIST_PROMO: 2, ADD_PRINTER_CLICKED: 3, REGISTER_CLICKED: 4, REGISTER_CONFIRMED: 5, REGISTER_SUCCESS: 6, REGISTER_CANCEL: 7, REGISTER_FAILURE: 8, MANAGE_CLICKED: 9, REGISTER_CANCEL_ON_PRINTER: 10, REGISTER_TIMEOUT: 11, LOG_IN_STARTED_FROM_REGISTER_OVERLAY_PROMO: 12, MAX_EVENT: 13, }; /** * Map of service names to corresponding service objects. * @type {Object} */ var devices = {}; /** * Whether or not the user is currently logged in. * @type bool */ var isUserLoggedIn = true; /** * Whether or not the user is supervised or off the record. * @type bool */ var isUserSupervisedOrOffTheRecord = false; /** * Whether or not the path-based dialog has been shown. * @type bool */ var dialogFromPathHasBeenShown = false; /** * Focus manager for page. */ var focusManager = null; /** * Object that represents a device in the device list. * @param {Object} info Information about the device. * @constructor */ function Device(info, registerEnabled) { this.info = info; this.domElement = null; this.registerButton = null; this.registerEnabled = registerEnabled; } Device.prototype = { /** * Update the device. * @param {Object} info New information about the device. */ updateDevice: function(info) { this.info = info; this.renderDevice(); }, /** * Delete the device. */ removeDevice: function() { this.deviceContainer().removeChild(this.domElement); }, /** * Render the device to the device list. */ renderDevice: function() { if (this.domElement) { clearElement(this.domElement); } else { this.domElement = document.createElement('div'); this.deviceContainer().appendChild(this.domElement); } this.registerButton = fillDeviceDescription( this.domElement, this.info.display_name, this.info.description, this.info.type, loadTimeData.getString('serviceRegister'), this.showRegister.bind(this, this.info.type)); this.setRegisterEnabled(this.registerEnabled); }, /** * Return the correct container for the device. * @param {boolean} is_mine Whether or not the device is in the 'Registered' * section. */ deviceContainer: function() { return $('register-device-list'); }, /** * Register the device. */ register: function() { recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_CONFIRMED); chrome.send('registerDevice', [this.info.service_name]); setRegisterPage( isPrinter(this.info.type) ? 'register-printer-page-adding1' : 'register-device-page-adding1'); }, /** * Show registrtation UI for device. */ showRegister: function() { recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_CLICKED); $('register-message').textContent = loadTimeData.getStringF( isPrinter(this.info.type) ? 'registerPrinterConfirmMessage' : 'registerDeviceConfirmMessage', this.info.display_name); $('register-continue-button').onclick = this.register.bind(this); showRegisterOverlay(); }, /** * Set registration button enabled/disabled */ setRegisterEnabled: function(isEnabled) { this.registerEnabled = isEnabled; if (this.registerButton) { this.registerButton.disabled = !isEnabled; } } }; /** * Manages focus for local devices page. * @constructor * @extends {cr.ui.FocusManager} */ function LocalDiscoveryFocusManager() { cr.ui.FocusManager.call(this); this.focusParent_ = document.body; } LocalDiscoveryFocusManager.prototype = { __proto__: cr.ui.FocusManager.prototype, /** @override */ getFocusParent: function() { return document.querySelector('#overlay .showing') || $('main-page'); } }; /** * Returns a textual representation of the number of printers on the network. * @return {string} Number of printers on the network as localized string. */ function generateNumberPrintersAvailableText(numberPrinters) { if (numberPrinters == 0) { return loadTimeData.getString('printersOnNetworkZero'); } else if (numberPrinters == 1) { return loadTimeData.getString('printersOnNetworkOne'); } else { return loadTimeData.getStringF( 'printersOnNetworkMultiple', numberPrinters); } } /** * Fill device element with the description of a device. * @param {HTMLElement} device_dom_element Element to be filled. * @param {string} name Name of device. * @param {string} description Description of device. * @param {string} type Type of device. * @param {string} button_text Text to appear on button. * @param {function()?} button_action Action for button. * @return {HTMLElement} The button (for enabling/disabling/rebinding) */ function fillDeviceDescription( device_dom_element, name, description, type, button_text, button_action) { device_dom_element.classList.add('device'); if (isPrinter(type)) device_dom_element.classList.add('printer'); var deviceInfo = document.createElement('div'); deviceInfo.className = 'device-info'; device_dom_element.appendChild(deviceInfo); var deviceName = document.createElement('h3'); deviceName.className = 'device-name'; deviceName.textContent = name; deviceInfo.appendChild(deviceName); var deviceDescription = document.createElement('div'); deviceDescription.className = 'device-subline'; deviceDescription.textContent = description; deviceInfo.appendChild(deviceDescription); if (button_action) { var button = document.createElement('button'); button.textContent = button_text; button.addEventListener('click', button_action); device_dom_element.appendChild(button); } return button; } /** * Show the register overlay. */ function showRegisterOverlay() { recordUmaEvent(DEVICES_PAGE_EVENTS.ADD_PRINTER_CLICKED); var registerOverlay = $('register-overlay'); registerOverlay.classList.add('showing'); registerOverlay.focus(); $('overlay').hidden = false; setRegisterPage('register-page-confirm'); } /** * Hide the register overlay. */ function hideRegisterOverlay() { $('register-overlay').classList.remove('showing'); $('overlay').hidden = true; } /** * Clear a DOM element of all children. * @param {HTMLElement} element DOM element to clear. */ function clearElement(element) { while (element.firstChild) { element.removeChild(element.firstChild); } } /** * Announce that a registration failed. */ function onRegistrationFailed() { $('error-message').textContent = loadTimeData.getString('addingErrorMessage'); setRegisterPage('register-page-error'); recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_FAILURE); } /** * Announce that a registration has been canceled on the printer. */ function onRegistrationCanceledPrinter() { $('error-message').textContent = loadTimeData.getString('addingCanceledMessage'); setRegisterPage('register-page-error'); recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_CANCEL_ON_PRINTER); } /** * Announce that a registration has timed out. */ function onRegistrationTimeout() { $('error-message').textContent = loadTimeData.getString('addingTimeoutMessage'); setRegisterPage('register-page-error'); recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_TIMEOUT); } /** * Update UI to reflect that registration has been confirmed on the printer. */ function onRegistrationConfirmedOnPrinter() { setRegisterPage('register-printer-page-adding2'); } /** * Shows UI to confirm security code. * @param {string} code The security code to confirm. */ function onRegistrationConfirmDeviceCode(code) { setRegisterPage('register-device-page-adding2'); $('register-device-page-code').textContent = code; } /** * Update device unregistered device list, and update related strings to * reflect the number of devices available to register. * @param {string} name Name of the device. * @param {string} info Additional info of the device or null if the device * has been removed. */ function onUnregisteredDeviceUpdate(name, info) { if (info) { if (devices.hasOwnProperty(name)) { devices[name].updateDevice(info); } else { devices[name] = new Device(info, isUserLoggedIn); devices[name].renderDevice(); } if (name == getOverlayIDFromPath() && !dialogFromPathHasBeenShown) { dialogFromPathHasBeenShown = true; devices[name].showRegister(); } } else { if (devices.hasOwnProperty(name)) { devices[name].removeDevice(); delete devices[name]; } } updateUIToReflectState(); } /** * Create the DOM for a cloud device described by the device section. * @param {Array} devices_list List of devices. */ function createCloudDeviceDOM(device) { var devicesDomElement = document.createElement('div'); var description; if (device.description == '') { if (isPrinter(device.type)) description = loadTimeData.getString('noDescriptionPrinter'); else description = loadTimeData.getString('noDescriptionDevice'); } else { description = device.description; } fillDeviceDescription( devicesDomElement, device.display_name, description, device.type, loadTimeData.getString('manageDevice'), isPrinter(device.type) ? manageCloudDevice.bind(null, device.id) : null); return devicesDomElement; } /** * Handle a list of cloud devices available to the user globally. * @param {Array} devices_list List of devices. */ function onCloudDeviceListAvailable(devices_list) { var devicesListLength = devices_list.length; var devicesContainer = $('cloud-devices'); clearElement(devicesContainer); $('cloud-devices-loading').hidden = true; for (var i = 0; i < devicesListLength; i++) { devicesContainer.appendChild(createCloudDeviceDOM(devices_list[i])); } } /** * Handle the case where the list of cloud devices is not available. */ function onCloudDeviceListUnavailable() { if (isUserLoggedIn) { $('cloud-devices-loading').hidden = true; $('cloud-devices-unavailable').hidden = false; } } /** * Handle the case where the cache for local devices has been flushed.. */ function onDeviceCacheFlushed() { for (var deviceName in devices) { devices[deviceName].removeDevice(); delete devices[deviceName]; } updateUIToReflectState(); } /** * Update UI strings to reflect the number of local devices. */ function updateUIToReflectState() { var numberPrinters = $('register-device-list').children.length; if (numberPrinters == 0) { $('no-printers-message').hidden = false; $('register-login-promo').hidden = true; } else { $('no-printers-message').hidden = true; $('register-login-promo').hidden = isUserLoggedIn || isUserSupervisedOrOffTheRecord; } if (!($('register-login-promo').hidden) || !($('cloud-devices-login-promo').hidden) || !($('register-overlay-login-promo').hidden)) { chrome.send( 'metricsHandler:recordAction', ['Signin_Impression_FromDevicesPage']); } } /** * Announce that a registration succeeeded. */ function onRegistrationSuccess(device_data) { hideRegisterOverlay(); if (device_data.service_name == getOverlayIDFromPath()) { window.close(); } var deviceDOM = createCloudDeviceDOM(device_data); $('cloud-devices').insertBefore(deviceDOM, $('cloud-devices').firstChild); recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_SUCCESS); } /** * Update visibility status for page. */ function updateVisibility() { chrome.send('isVisible', [!document.hidden]); } /** * Set the page that the register wizard is on. * @param {string} page_id ID string for page. */ function setRegisterPage(page_id) { var pages = $('register-overlay').querySelectorAll('.register-page'); var pagesLength = pages.length; for (var i = 0; i < pagesLength; i++) { pages[i].hidden = true; } $(page_id).hidden = false; } /** * Request the device list. */ function requestDeviceList() { if (isUserLoggedIn) { clearElement($('cloud-devices')); $('cloud-devices-loading').hidden = false; $('cloud-devices-unavailable').hidden = true; chrome.send('requestDeviceList'); } } /** * Go to management page for a cloud device. * @param {string} device_id ID of device. */ function manageCloudDevice(device_id) { recordUmaEvent(DEVICES_PAGE_EVENTS.MANAGE_CLICKED); chrome.send('openCloudPrintURL', [device_id]); } /** * Record an event in the UMA histogram. * @param {number} eventId The id of the event to be recorded. * @private */ function recordUmaEvent(eventId) { chrome.send( 'metricsHandler:recordInHistogram', ['LocalDiscovery.DevicesPage', eventId, DEVICES_PAGE_EVENTS.MAX_EVENT]); } /** * Cancel the registration. */ function cancelRegistration() { hideRegisterOverlay(); chrome.send('cancelRegistration'); recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_CANCEL); } /** * Confirms device code. */ function confirmCode() { chrome.send('confirmCode'); setRegisterPage('register-device-page-adding1'); } /** * Retry loading the devices from Google Cloud Print. */ function retryLoadCloudDevices() { requestDeviceList(); } /** * User is not logged in. */ function setUserLoggedIn(userLoggedIn, userSupervisedOrOffTheRecord) { isUserLoggedIn = userLoggedIn; isUserSupervisedOrOffTheRecord = userSupervisedOrOffTheRecord; $('cloud-devices-login-promo').hidden = isUserLoggedIn || isUserSupervisedOrOffTheRecord; $('register-overlay-login-promo').hidden = isUserLoggedIn || isUserSupervisedOrOffTheRecord; $('register-continue-button').disabled = !isUserLoggedIn || isUserSupervisedOrOffTheRecord; $('my-devices-container').hidden = userSupervisedOrOffTheRecord; if (isUserSupervisedOrOffTheRecord) { $('cloud-print-connector-section').hidden = true; } if (isUserLoggedIn && !isUserSupervisedOrOffTheRecord) { requestDeviceList(); $('register-login-promo').hidden = true; } else { $('cloud-devices-loading').hidden = true; $('cloud-devices-unavailable').hidden = true; clearElement($('cloud-devices')); hideRegisterOverlay(); } updateUIToReflectState(); for (var device in devices) { devices[device].setRegisterEnabled(isUserLoggedIn); } } function openSignInPage() { chrome.send('showSyncUI'); } function registerLoginButtonClicked() { recordUmaEvent(DEVICES_PAGE_EVENTS.LOG_IN_STARTED_FROM_REGISTER_PROMO); openSignInPage(); } function registerOverlayLoginButtonClicked() { recordUmaEvent( DEVICES_PAGE_EVENTS.LOG_IN_STARTED_FROM_REGISTER_OVERLAY_PROMO); openSignInPage(); } function cloudDevicesLoginButtonClicked() { recordUmaEvent(DEVICES_PAGE_EVENTS.LOG_IN_STARTED_FROM_DEVICE_LIST_PROMO); openSignInPage(); } /** * Set the Cloud Print proxy UI to enabled, disabled, or processing. * @private */ function setupCloudPrintConnectorSection(disabled, label, allowed) { if (!cr.isChromeOS) { $('cloudPrintConnectorLabel').textContent = label; if (disabled || !allowed) { $('cloudPrintConnectorSetupButton').textContent = loadTimeData.getString('cloudPrintConnectorDisabledButton'); } else { $('cloudPrintConnectorSetupButton').textContent = loadTimeData.getString('cloudPrintConnectorEnabledButton'); } $('cloudPrintConnectorSetupButton').disabled = !allowed; if (disabled) { $('cloudPrintConnectorSetupButton').onclick = function(event) { // Disable the button, set its text to the intermediate state. $('cloudPrintConnectorSetupButton').textContent = loadTimeData.getString('cloudPrintConnectorEnablingButton'); $('cloudPrintConnectorSetupButton').disabled = true; chrome.send('showCloudPrintSetupDialog'); }; } else { $('cloudPrintConnectorSetupButton').onclick = function(event) { chrome.send('disableCloudPrintConnector'); requestDeviceList(); }; } } } function getOverlayIDFromPath() { if (document.location.pathname == '/register') { var params = parseQueryParams(document.location); return params['id'] || null; } } /** * Returns true of device is printer. * @param {string} type Type of printer. */ function isPrinter(type) { return type == 'printer'; } document.addEventListener('DOMContentLoaded', function() { cr.ui.overlay.setupOverlay($('overlay')); cr.ui.overlay.globalInitialization(); $('overlay').addEventListener('cancelOverlay', cancelRegistration); [].forEach.call( document.querySelectorAll('.register-cancel'), function(button) { button.addEventListener('click', cancelRegistration); }); [].forEach.call( document.querySelectorAll('.confirm-code'), function(button) { button.addEventListener('click', confirmCode); }); $('register-error-exit').addEventListener('click', cancelRegistration); $('cloud-devices-retry-link') .addEventListener('click', retryLoadCloudDevices); $('cloud-devices-login-link') .addEventListener('click', cloudDevicesLoginButtonClicked); $('register-login-link') .addEventListener('click', registerLoginButtonClicked); $('register-overlay-login-button') .addEventListener('click', registerOverlayLoginButtonClicked); if (loadTimeData.valueExists('backButtonURL')) { $('back-link').hidden = false; $('back-link').addEventListener('click', function() { window.location.href = loadTimeData.getString('backButtonURL'); }); } updateVisibility(); document.addEventListener('visibilitychange', updateVisibility, false); focusManager = new LocalDiscoveryFocusManager(); focusManager.initialize(); chrome.send('start'); recordUmaEvent(DEVICES_PAGE_EVENTS.OPENED); }); return { onRegistrationSuccess: onRegistrationSuccess, onRegistrationFailed: onRegistrationFailed, onUnregisteredDeviceUpdate: onUnregisteredDeviceUpdate, onRegistrationConfirmedOnPrinter: onRegistrationConfirmedOnPrinter, onRegistrationConfirmDeviceCode: onRegistrationConfirmDeviceCode, onCloudDeviceListAvailable: onCloudDeviceListAvailable, onCloudDeviceListUnavailable: onCloudDeviceListUnavailable, onDeviceCacheFlushed: onDeviceCacheFlushed, onRegistrationCanceledPrinter: onRegistrationCanceledPrinter, onRegistrationTimeout: onRegistrationTimeout, setUserLoggedIn: setUserLoggedIn, setupCloudPrintConnectorSection: setupCloudPrintConnectorSection, }; }); // 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 Helpers for validating parameters to chrome-search:// iframes. */ /** * Converts an RGB color number to a hex color string if valid. * @param {number} color A 6-digit hex RGB color code as a number. * @return {?string} A CSS representation of the color or null if invalid. */ function convertToHexColor(color) { // Color must be a number, finite, with no fractional part, in the correct // range for an RGB hex color. if (isFinite(color) && Math.floor(color) == color && color >= 0 && color <= 0xffffff) { var hexColor = color.toString(16); // Pads with initial zeros and # (e.g. for 'ff' yields '#0000ff'). return '#000000'.substr(0, 7 - hexColor.length) + hexColor; } return null; } /** * Validates a RGBA color component. It must be a number between 0 and 255. * @param {number} component An RGBA component. * @return {boolean} True if the component is valid. */ function isValidRBGAComponent(component) { return isFinite(component) && component >= 0 && component <= 255; } /** * Converts an Array of color components into RGBA format "rgba(R,G,B,A)". * @param {Array} rgbaColor Array of rgba color components. * @return {?string} CSS color in RGBA format or null if invalid. */ function convertArrayToRGBAColor(rgbaColor) { // Array must contain 4 valid components. if (rgbaColor instanceof Array && rgbaColor.length === 4 && isValidRBGAComponent(rgbaColor[0]) && isValidRBGAComponent(rgbaColor[1]) && isValidRBGAComponent(rgbaColor[2]) && isValidRBGAComponent(rgbaColor[3])) { return 'rgba(' + rgbaColor[0] + ',' + rgbaColor[1] + ',' + rgbaColor[2] + ',' + rgbaColor[3] / 255 + ')'; } return null; } /* Copyright 2017 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 template provides the styles used by the Speech feature. There is one UI * used for on the local NTP: the Full Page UI ("overlay") that takes over the * whole page. * * This mode also has a hidden and a visible state to allow for show and hide * animations. As such, there are 2 different UIs, specified by different * modifier classes (the class is applied to the Element with id=voice-overlay): * - Hidden in the Full Page view (parent class: overlay-hidden). * - Visible in the Full Page view (parent class: overlay). * * In addition, speech recognition can be in one of 5 different states that can * manifest in each of the UIs (the corresponding class names are applied to the * element with id=outer): * - Listening for audio (parent class: voice-ml). * - Receiving speech (parent class: voice-rs). * - Error received (parent class: voice-er). * - Inactive (no parent class). * * For details, see go/gws-speech-design and go/local-ntp-voice-search. */ /* Color constants. */ :root { --dark_red: rgb(205, 0, 0); --grey: #777; --light_grey: #eee; --light_red: rgb(255, 68, 68); --active_icon_color: white; --button_shadow: rgba(0, 0, 0, .1); --inactive_icon_color: #999; --level_animation_color: #dbdbdb; --listening_icon_color: var(--light_red); --text_link_color: rgb(17, 85, 204); } /* The background element. */ .overlay, .overlay-hidden { background: white; height: 100%; left: 0; opacity: 0; overflow: hidden; position: fixed; text-align: left; top: 0; transition: visibility 0s linear 218ms, opacity 218ms, background-color 218ms; visibility: hidden; width: 100%; z-index: 10000; } /* Full Page visible style for the background element. */ .overlay { opacity: 1; transition-delay: 0s; visibility: visible; } /* The close 'x' button. */ .close-button { color: black; cursor: pointer; font-size: 26px; height: 11px; line-height: 15px; margin: 15px; opacity: .54; padding: 0; position: absolute; right: 0; top: 0; width: 15px; } html[dir=rtl] .close-button { left: 0; right: auto; } .close-button:hover { opacity: .66; } .close-button:active { opacity: .78; } /* The vertical positioning container. */ .outer { display: block; height: 42px; pointer-events: none; position: absolute; } /* Full Page visible and hidden state of the positioning container. */ .overlay .outer, .overlay-hidden .outer { margin: auto; margin-top: 312px; max-width: 572px; min-width: 534px; padding: 0 223px; position: relative; top: 0; } /* Style for the inner container used for horizontal positioning. */ .inner-container { height: 100%; opacity: .1; pointer-events: none; transition: opacity 318ms ease-in; width: 100%; } .voice-ml .inner-container, .voice-rs .inner-container, .voice-er .inner-container { opacity: 1; transition: opacity 0s; } /* MICROPHONE BUTTON */ /* Button with microphone icon in center from which pulses and vibrations * emanate. */ .button { background-color: white; border: 1px solid var(--light_grey); border-radius: 100%; bottom: 0; box-shadow: 0 2px 5px var(--button_shadow); cursor: pointer; display: inline-block; left: 0; opacity: 0; pointer-events: none; position: absolute; right: 0; top: 0; transition: background-color 218ms, border 218ms, box-shadow 218ms; } /* Button state when speech recognition is inactive. */ .overlay-hidden .button { opacity: 0; pointer-events: none; position: absolute; transition-delay: 0; } /* Button state when speech recognition is active. */ .overlay .button { opacity: 1; pointer-events: auto; position: absolute; transition-delay: 0; } /* Button state when speech input is being received by the microphone. */ .voice-rs .button { background-color: var(--light_red); border: 0; box-shadow: none; } /* Vibrating input volume level. */ .level { background-color: var(--level_animation_color); border-radius: 100%; display: inline-block; height: 301px; left: -69px; opacity: 1; pointer-events: none; position: absolute; top: -69px; transform: scale(.01); transition: opacity 218ms; width: 301px; } /* Container for scaling and positioning of the button. */ .button-container { float: right; pointer-events: none; position: relative; transition: transform 218ms, opacity 218ms ease-in; } html[dir=rtl] .button-container { float: left; } /* Common styles applied to the button-container. */ .overlay .button-container, .overlay-hidden .button-container { height: 165px; right: -70px; top: -70px; width: 165px; } html[dir=rtl] .overlay .button-container, html[dir=rtl] .overlay-hidden .button-container { left: -70px; right: auto; } /* Container style when speech recognition is inactive. */ .overlay-hidden .button-container { transform: scale(.1); } /* Style applied to the button when clicked in the 'receiving audio' state. */ .voice-rs .button:active { background-color: var(--dark_red); } /* Style applied to the button when clicked. */ .button:active { background-color: var(--light_grey); } /* TEXT */ /* Classes: * - voice-text - Text area style class * - voice-text-2l - 2 line style class * - voice-text-3l - 3 line style class * - voice-text-4l - 4 line style class * - voice-text-5l - 5 line style class */ /* Styles applied to the positioning text-container element. */ .text-container { pointer-events: none; } /* Full Page UI style for the text-container. */ .overlay .text-container, .overlay-hidden .text-container { position: absolute; } /* This class is used to specify the speech recognition text formatting. */ .voice-text { font-weight: normal; line-height: 1.2; opacity: 0; pointer-events: none; position: absolute; text-align: left; transition: opacity 100ms ease-in, margin-left 500ms ease-in, top 0s linear 218ms; } html[dir=rtl] .voice-text { text-align: right; } /* Recognition results text hidden in the Full Page UI. */ .overlay-hidden .voice-text { margin-left: 44px; } html[dir=rtl] .overlay-hidden .voice-text { margin-left: 0; margin-right: 44px; } /* Styles applied to the text output elements. Common style for the text area * class for the full Page UI. To vertically center the text as longer queries * are wrapped, the 'top' position is specified in em here and below. */ .overlay .voice-text, .overlay-hidden .voice-text { font-size: 32px; left: -44px; top: -.2em; width: 460px; } html[dir=rtl] .overlay .voice-text, html[dir=rtl] .overlay-hidden .voice-text { left: auto; right: -44px; } /* Common style for when the text areas are made visible. */ .overlay .voice-text { margin-left: 0; opacity: 1; transition: opacity 500ms ease-out, margin-left 500ms ease-out; } html[dir=rtl] .overlay .voice-text { margin-left: auto; margin-right: 0; } /* Interim (low confidence) text. */ #voice-text-i { color: var(--grey); } /* Final (high confidence) text. */ #voice-text-f { color: black; } /* Text area links. */ .voice-text-link { color: var(--text_link_color); cursor: pointer; font-size: 18px; font-weight: 500; pointer-events: auto; text-decoration: underline; } /* Range of motion for the typing animation. */ @keyframes type { from { width: 0; } to { width: 460px; } } /* Style to simulate typing the "Listening..." text. */ .listening-animation { animation: type 900ms steps(30, end); overflow: hidden; white-space: nowrap; } /* Styles applied to simulate vertical scrolling. Common webkit transition * style for vertical text scrolling. */ .voice-text-2l.voice-text, .voice-text-3l.voice-text, .voice-text-4l.voice-text { transition: top 218ms ease-out; } /* When the text height is two lines. */ .voice-text-2l.voice-text { top: -.6em; } /* When the text height is three lines. */ .voice-text-3l.voice-text { top: -1.3em; } /* When the text height is four lines. */ .voice-text-4l.voice-text { top: -1.7em; } /* When the text height is more than five lines, shift the text up. */ .voice-text-5l.voice-text { top: -2.5em; } /* MICROPHONE ICON */ /* The microphone icon is made up of 4 parts: * - the audio receiver, * - the shell that surrounds the lower half of the audio receiver, * - the stem that holds up the shell and the audio receiver, * - and a wrapper that positions the shell and stem. * * /===\ * | | <==== Audio receiver. * | | * \ \===/ / <== Shell. * \_______/ * | * | <====== Stem. */ /* Container element for microphone icon. */ .microphone { direction: ltr; height: 87px; left: 43px; pointer-events: none; position: absolute; top: 47px; width: 42px; } /* Part 1 of CSS-only microphone icon: the audio receiver. * Positioned in the center. */ .receiver { background-color: var(--inactive_icon_color); border-radius: 30px; height: 46px; left: 25px; pointer-events: none; position: absolute; width: 24px; } /* Part 2 of CSS-only microphone icon: the shell and stem wrapper element. * Positioned below the audio receiver element. */ .wrapper { bottom: 0; height: 53px; left: 11px; overflow: hidden; pointer-events: none; position: absolute; width: 52px; } /* Part 3 of CSS-only microphone icon: the stem that supports the shell. * Positioned below the audio receiver element and the shell element. */ .stem { background-color: var(--inactive_icon_color); bottom: 14px; height: 14px; left: 22px; pointer-events: none; position: absolute; width: 9px; z-index: 1; /* z-index is only used to specify position relative to stem. */ } /* Part 4 of CSS-only microphone icon: shell that holds the receiver. * Positioned below the audio receiver element and above the stem element. */ .shell { border: 7px solid var(--inactive_icon_color); border-radius: 28px; bottom: 27px; height: 57px; pointer-events: none; position: absolute; width: 38px; z-index: 0; /* z-index is only used to specify position relative to stem. */ } /* The .voice-ml style is applied when the UI is in * the 'listening for audio' state. */ .voice-ml .receiver, .voice-ml .stem { background-color: var(--listening_icon_color); } .voice-ml .shell { border-color: var(--listening_icon_color); } /* The .voice-rs style is applied when the UI is in * the 'receiving speech' state. */ .voice-rs .receiver, .voice-rs .stem { background-color: var(--active_icon_color); } .voice-rs .shell { border-color: var(--active_icon_color); } // Copyright 2017 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'; /** * 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) { // eslint-disable-next-line no-restricted-properties return document.getElementById(id); } /** * Get the preferred language for UI localization. Represents Chrome's UI * language, which might not coincide with the user's "preferred" language * in the Settings. For more details, see: * - https://developer.mozilla.org/en/docs/Web/API/NavigatorLanguage/language * - https://developer.mozilla.org/en/docs/Web/API/NavigatorLanguage/languages * * The returned value is a language version string as defined in * BCP 47. * Examples: "en", "en-US", "cs-CZ", etc. */ function getChromeUILanguage() { // In Chrome, |window.navigator.language| is not guaranteed to be equal to // |window.navigator.languages[0]|. return window.navigator.language; } /** * The different types of user action and error events that are logged * from Voice Search. This enum is used to transfer information to * the renderer and is not used as a UMA enum histogram's logged value. * Note: Keep in sync with common/ntp_logging_events.h * @enum {!number} * @const */ const LOG_TYPE = { // Activated by clicking on the fakebox icon. ACTION_ACTIVATE_FAKEBOX: 13, // Activated by keyboard shortcut. ACTION_ACTIVATE_KEYBOARD: 14, // Close the voice overlay by a user's explicit action. ACTION_CLOSE_OVERLAY: 15, // Submitted voice query. ACTION_QUERY_SUBMITTED: 16, // Clicked on support link in error message. ACTION_SUPPORT_LINK_CLICKED: 17, // Retried by clicking Try Again link. ACTION_TRY_AGAIN_LINK: 18, // Retried by clicking microphone button. ACTION_TRY_AGAIN_MIC_BUTTON: 10, // Errors received from the Speech Recognition API. ERROR_NO_SPEECH: 20, ERROR_ABORTED: 21, ERROR_AUDIO_CAPTURE: 22, ERROR_NETWORK: 23, ERROR_NOT_ALLOWED: 24, ERROR_SERVICE_NOT_ALLOWED: 25, ERROR_BAD_GRAMMAR: 26, ERROR_LANGUAGE_NOT_SUPPORTED: 27, ERROR_NO_MATCH: 28, ERROR_OTHER: 29 }; /** * Enum for keyboard event codes. * @enum {!string} * @const */ const KEYCODE = { ENTER: 'Enter', ESC: 'Escape', NUMPAD_ENTER: 'NumpadEnter', PERIOD: 'Period', SPACE: 'Space' }; /** * The set of possible recognition errors. * @enum {!number} * @const */ const RecognitionError = { NO_SPEECH: 0, ABORTED: 1, AUDIO_CAPTURE: 2, NETWORK: 3, NOT_ALLOWED: 4, SERVICE_NOT_ALLOWED: 5, BAD_GRAMMAR: 6, LANGUAGE_NOT_SUPPORTED: 7, NO_MATCH: 8, OTHER: 9 }; /** * Provides methods for communicating with the * Web Speech API, error handling and executing search queries. */ let speech = {}; /** * Localized translations for messages used in the Speech UI. * @type {{ * audioError: string, * details: string, * languageError: string, * learnMore: string, * listening: string, * networkError: string, * noTranslation: string, * noVoice: string, * otherError: string, * permissionError: string, * ready: string, * tryAgain: string, * waiting: string * }} */ speech.messages = { audioError: '', details: '', languageError: '', learnMore: '', listening: '', networkError: '', noTranslation: '', noVoice: '', otherError: '', permissionError: '', ready: '', tryAgain: '', waiting: '' }; /** * The set of controller states. * @enum {number} * @private */ speech.State_ = { // Initial state of the controller. It is never re-entered. // The only state from which the |speech.init()| method can be called. // The UI overlay is hidden, recognition is inactive. UNINITIALIZED: -1, // Represents a ready to be activated state. If voice search is unsuccessful // for any reason, the controller will return to this state // using |speech.reset_()|. The UI overlay is hidden, recognition is inactive. READY: 0, // Indicates that speech recognition has started, but no audio has yet // been captured. The UI overlay is visible, recognition is active. STARTED: 1, // Indicates that audio is being captured by the Web Speech API, but no // speech has yet been recognized. The UI overlay is visible and indicating // that audio is being captured, recognition is active. AUDIO_RECEIVED: 2, // Represents a state where speech has been recognized by the Web Speech API, // but no resulting transcripts have yet been received back. The UI overlay is // visible and indicating that audio is being captured, recognition is active. SPEECH_RECEIVED: 3, // Controller state where speech has been successfully recognized and text // transcripts have been reported back. The UI overlay is visible // and displaying intermediate results, recognition is active. // This state remains until recognition ends successfully or due to an error. RESULT_RECEIVED: 4, // Indicates that speech recognition has failed due to an error // (or a no match error) being received from the Web Speech API. // A timeout may have occurred as well. The UI overlay is visible // and displaying an error message, recognition is inactive. ERROR_RECEIVED: 5, // Represents a state where speech recognition has been stopped // (either on success or failure) and the UI has not yet reset/redirected. // The UI overlay is displaying results or an error message with a timeout, // after which the site will either get redirected to search results // (successful) or back to the NTP by hiding the overlay (unsuccessful). STOPPED: 6 }; /** * Threshold for considering an interim speech transcript result as "confident * enough". The more confident the API is about a transcript, the higher the * confidence (number between 0 and 1). * @private {number} * @const */ speech.RECOGNITION_CONFIDENCE_THRESHOLD_ = 0.5; /** * Time in milliseconds to wait before closing the UI after an error has * occured. This is a short timeout used when no click-target is present. * @private {number} * @const */ speech.ERROR_TIMEOUT_SHORT_MS_ = 3000; /** * Time in milliseconds to wait before closing the UI after an error has * occured. This is a longer timeout used when there is a click-target is * present. * @private {number} * @const */ speech.ERROR_TIMEOUT_LONG_MS_ = 8000; /** * Time in milliseconds to wait before closing the UI if no interaction has * occured. * @private {number} * @const */ speech.IDLE_TIMEOUT_MS_ = 8000; /** * Maximum number of characters recognized before force-submitting a query. * Includes characters of non-confident recognition transcripts. * @private {number} * @const */ speech.QUERY_LENGTH_LIMIT_ = 120; /** * Specifies the current state of the controller. * Note: Different than the UI state. * @private {speech.State_} */ speech.currentState_ = speech.State_.UNINITIALIZED; /** * The ID for the error timer. * @private {number} */ speech.errorTimer_; /** * The duration of the timeout for the UI elements during an error state. * Depending on the error state, we have different durations for the timeout. * @private {number} */ speech.errorTimeoutMs_ = 0; /** * The last high confidence voice transcript received from the Web Speech API. * This is the actual query that could potentially be submitted to Search. * @private {string} */ speech.finalResult_; /** * Base URL for sending queries to Search. Includes trailing forward slash. * @private {string} */ speech.googleBaseUrl_; /** * The ID for the idle timer. * @private {number} */ speech.idleTimer_; /** * The last low confidence voice transcript received from the Web Speech API. * @private {string} */ speech.interimResult_; /** * The Web Speech API object driving the speech recognition transaction. * @private {!webkitSpeechRecognition} */ speech.recognition_; /** * Log an event from Voice Search. * @param {!number} eventType Event from |LOG_TYPE|. */ speech.logEvent = function(eventType) { window.chrome.embeddedSearch.newTabPage.logEvent(eventType); }; /** * Initialize the speech module as part of the local NTP. Adds event handlers * and shows the fakebox microphone icon. * @param {!string} googleBaseUrl Base URL for sending queries to Search. * @param {!Object} translatedStrings Dictionary of localized string messages. * @param {!HTMLElement} fakeboxMicrophoneElem Fakebox microphone icon element. * @param {!Object} searchboxApiHandle SearchBox API handle. */ speech.init = function( googleBaseUrl, translatedStrings, fakeboxMicrophoneElem, searchboxApiHandle) { if (speech.currentState_ != speech.State_.UNINITIALIZED) { throw new Error( 'Trying to re-initialize speech when not in UNINITIALIZED state.'); } // Initialize event handlers. fakeboxMicrophoneElem.hidden = false; fakeboxMicrophoneElem.title = translatedStrings.fakeboxMicrophoneTooltip; fakeboxMicrophoneElem.onclick = function(event) { // If propagated, closes the overlay (click on the background). event.stopPropagation(); speech.logEvent(LOG_TYPE.ACTION_ACTIVATE_FAKEBOX); speech.start(); }; fakeboxMicrophoneElem.onkeydown = function(event) { if (!event.repeat && speech.isSpaceOrEnter_(event.code) && speech.currentState_ == speech.State_.READY) { event.stopPropagation(); speech.start(); } }; window.addEventListener('keydown', speech.onKeyDown); if (searchboxApiHandle.onfocuschange) { throw new Error('OnFocusChange handler already set on searchbox.'); } searchboxApiHandle.onfocuschange = speech.onOmniboxFocused; // Initialize speech internal state. speech.googleBaseUrl_ = googleBaseUrl; speech.messages = { audioError: translatedStrings.audioError, details: translatedStrings.details, languageError: translatedStrings.languageError, learnMore: translatedStrings.learnMore, listening: translatedStrings.listening, networkError: translatedStrings.networkError, noTranslation: translatedStrings.noTranslation, noVoice: translatedStrings.noVoice, otherError: translatedStrings.otherError, permissionError: translatedStrings.permissionError, ready: translatedStrings.ready, tryAgain: translatedStrings.tryAgain, waiting: translatedStrings.waiting, }; view.init(speech.onClick_); speech.initWebkitSpeech_(); speech.reset_(); }; /** * Initializes and configures the speech recognition API. * @private */ speech.initWebkitSpeech_ = function() { speech.recognition_ = new webkitSpeechRecognition(); speech.recognition_.continuous = false; speech.recognition_.interimResults = true; speech.recognition_.lang = getChromeUILanguage(); speech.recognition_.onaudiostart = speech.handleRecognitionAudioStart_; speech.recognition_.onend = speech.handleRecognitionEnd_; speech.recognition_.onerror = speech.handleRecognitionError_; speech.recognition_.onnomatch = speech.handleRecognitionOnNoMatch_; speech.recognition_.onresult = speech.handleRecognitionResult_; speech.recognition_.onspeechstart = speech.handleRecognitionSpeechStart_; }; /** * Sets up the necessary states for voice search and then starts the * speech recognition interface. */ speech.start = function() { view.show(); speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); document.addEventListener( 'webkitvisibilitychange', speech.onVisibilityChange_, false); // Initialize |speech.recognition_| if it isn't already. if (!speech.recognition_) { speech.initWebkitSpeech_(); } // If |speech.start()| is called too soon after |speech.stop()| then the // recognition interface hasn't yet reset and an error occurs. In this case // we need to hard-reset it and reissue the |recognition_.start()| command. try { speech.recognition_.start(); speech.currentState_ = speech.State_.STARTED; } catch (error) { speech.initWebkitSpeech_(); try { speech.recognition_.start(); speech.currentState_ = speech.State_.STARTED; } catch (error2) { speech.stop(); } } }; /** * Hides the overlay and resets the speech state. */ speech.stop = function() { speech.recognition_.abort(); speech.currentState_ = speech.State_.STOPPED; view.hide(); speech.reset_(); }; /** * Resets the internal state to the READY state. * @private */ speech.reset_ = function() { window.clearTimeout(speech.idleTimer_); window.clearTimeout(speech.errorTimer_); document.removeEventListener( 'webkitvisibilitychange', speech.onVisibilityChange_, false); speech.interimResult_ = ''; speech.finalResult_ = ''; speech.currentState_ = speech.State_.READY; }; /** * Informs the view that the browser is receiving audio input. * @param {Event=} opt_event Emitted event for audio start. * @private */ speech.handleRecognitionAudioStart_ = function(opt_event) { speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); speech.currentState_ = speech.State_.AUDIO_RECEIVED; view.setReadyForSpeech(); }; /** * Function is called when the user starts speaking. * @param {Event=} opt_event Emitted event for speech start. * @private */ speech.handleRecognitionSpeechStart_ = function(opt_event) { speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); speech.currentState_ = speech.State_.SPEECH_RECEIVED; view.setReceivingSpeech(); }; /** * Processes the recognition results arriving from the Web Speech API. * @param {SpeechRecognitionEvent} responseEvent Event coming from the API. * @private */ speech.handleRecognitionResult_ = function(responseEvent) { speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); switch (speech.currentState_) { case speech.State_.RESULT_RECEIVED: case speech.State_.SPEECH_RECEIVED: // Normal, expected states for processing results. break; case speech.State_.AUDIO_RECEIVED: // Network bugginess (the onaudiostart packet was lost). speech.handleRecognitionSpeechStart_(); break; case speech.State_.STARTED: // Network bugginess (the onspeechstart packet was lost). speech.handleRecognitionAudioStart_(); speech.handleRecognitionSpeechStart_(); break; default: // Not expecting results in any other states. return; } const results = responseEvent.results; if (results.length == 0) { return; } speech.currentState_ = speech.State_.RESULT_RECEIVED; speech.interimResult_ = ''; speech.finalResult_ = ''; const finalResult = results[responseEvent.resultIndex]; // Process final results. if (finalResult.isFinal) { speech.finalResult_ = finalResult[0].transcript; view.updateSpeechResult(speech.finalResult_, speech.finalResult_); speech.submitFinalResult_(); return; } // Process interim results. for (let j = 0; j < results.length; j++) { const result = results[j][0]; speech.interimResult_ += result.transcript; if (result.confidence > speech.RECOGNITION_CONFIDENCE_THRESHOLD_) { speech.finalResult_ += result.transcript; } } view.updateSpeechResult(speech.interimResult_, speech.finalResult_); // Force-stop long queries. if (speech.interimResult_.length > speech.QUERY_LENGTH_LIMIT_) { if (!!speech.finalResult_) { speech.submitFinalResult_(); } else { speech.onErrorReceived_(RecognitionError.NO_MATCH); } } }; /** * Convert a |RecognitionError| to a |LOG_TYPE| error constant, * for UMA logging. * @param {RecognitionError} error The received error. * @private */ speech.errorToLogType_ = function(error) { switch (error) { case RecognitionError.ABORTED: return LOG_TYPE.ERROR_ABORTED; case RecognitionError.AUDIO_CAPTURE: return LOG_TYPE.ERROR_AUDIO_CAPTURE; case RecognitionError.BAD_GRAMMAR: return LOG_TYPE.ERROR_BAD_GRAMMAR; case RecognitionError.LANGUAGE_NOT_SUPPORTED: return LOG_TYPE.ERROR_LANGUAGE_NOT_SUPPORTED; case RecognitionError.NETWORK: return LOG_TYPE.ERROR_NETWORK; case RecognitionError.NO_MATCH: return LOG_TYPE.ERROR_NO_MATCH; case RecognitionError.NO_SPEECH: return LOG_TYPE.ERROR_NO_SPEECH; case RecognitionError.NOT_ALLOWED: return LOG_TYPE.ERROR_NOT_ALLOWED; case RecognitionError.SERVICE_NOT_ALLOWED: return LOG_TYPE.ERROR_SERVICE_NOT_ALLOWED; default: return LOG_TYPE.ERROR_OTHER; } }; /** * Handles state transition for the controller when an error occurs * during speech recognition. * @param {RecognitionError} error The appropriate error state from * the RecognitionError enum. * @private */ speech.onErrorReceived_ = function(error) { speech.logEvent(speech.errorToLogType_(error)); speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); speech.errorTimeoutMs_ = speech.getRecognitionErrorTimeout_(error); if (error != RecognitionError.ABORTED) { speech.currentState_ = speech.State_.ERROR_RECEIVED; view.showError(error); window.clearTimeout(speech.idleTimer_); speech.resetErrorTimer_(speech.errorTimeoutMs_); } }; /** * Called when an error from Web Speech API is received. * @param {SpeechRecognitionError} error The error event. * @private */ speech.handleRecognitionError_ = function(error) { speech.onErrorReceived_(speech.getRecognitionError_(error.error)); }; /** * Stops speech recognition when no matches are found. * @private */ speech.handleRecognitionOnNoMatch_ = function() { speech.onErrorReceived_(RecognitionError.NO_MATCH); }; /** * Stops the UI when the Web Speech API reports that it has halted speech * recognition. * @private */ speech.handleRecognitionEnd_ = function() { window.clearTimeout(speech.idleTimer_); let error; switch (speech.currentState_) { case speech.State_.STARTED: error = RecognitionError.AUDIO_CAPTURE; break; case speech.State_.AUDIO_RECEIVED: error = RecognitionError.NO_SPEECH; break; case speech.State_.SPEECH_RECEIVED: case speech.State_.RESULT_RECEIVED: error = RecognitionError.NO_MATCH; break; case speech.State_.ERROR_RECEIVED: error = RecognitionError.OTHER; break; default: return; } // If error has not yet been displayed. if (speech.currentState_ != speech.State_.ERROR_RECEIVED) { view.showError(error); speech.resetErrorTimer_(speech.ERROR_TIMEOUT_LONG_MS_); } speech.currentState_ = speech.State_.STOPPED; }; /** * Determines whether the user's browser is probably running on a Mac. * @return {boolean} True iff the user's browser is running on a Mac. * @private */ speech.isUserAgentMac_ = function() { return window.navigator.userAgent.includes('Macintosh'); }; /** * Determines, if the given KeyboardEvent |code| is a space or enter key. * @param {!string} A KeyboardEvent's |code| property. * @return True, iff the code represents a space or enter key. * @private */ speech.isSpaceOrEnter_ = function(code) { switch (code) { case KEYCODE.ENTER: case KEYCODE.NUMPAD_ENTER: case KEYCODE.SPACE: return true; default: return false; } }; /** * Handles the following keyboard actions. * - + + <.> starts voice input( + + <.> on mac). * - aborts voice input when the recognition interface is active. * - submits the speech query if there is one. * @param {KeyboardEvent} event The keydown event. */ speech.onKeyDown = function(event) { if (speech.isUiDefinitelyHidden_()) { const ctrlKeyPressed = event.ctrlKey || (speech.isUserAgentMac_() && event.metaKey); if (speech.currentState_ == speech.State_.READY && event.code == KEYCODE.PERIOD && event.shiftKey && ctrlKeyPressed) { speech.logEvent(LOG_TYPE.ACTION_ACTIVATE_KEYBOARD); speech.start(); } } else { // Ensures that keyboard events are not propagated during voice input. event.stopPropagation(); if (speech.isSpaceOrEnter_(event.code) && speech.finalResult_) { speech.submitFinalResult_(); } else if ( speech.isSpaceOrEnter_(event.code) || event.code == KEYCODE.ESC) { speech.logEvent(LOG_TYPE.ACTION_CLOSE_OVERLAY); speech.stop(); } } }; /** * Displays the no match error if no interactions occur after some time while * the interface is active. This is a safety net in case the onend event * doesn't fire, or the user has persistent noise in the background, and does * not speak. If a high confidence transcription was received, then this submits * the search query instead of displaying an error. * @private */ speech.onIdleTimeout_ = function() { if (!!speech.finalResult_) { speech.submitFinalResult_(); return; } switch (speech.currentState_) { case speech.State_.STARTED: case speech.State_.AUDIO_RECEIVED: case speech.State_.SPEECH_RECEIVED: case speech.State_.RESULT_RECEIVED: case speech.State_.ERROR_RECEIVED: speech.onErrorReceived_(RecognitionError.NO_MATCH); break; } }; /** * Aborts the speech recognition interface when the user switches to a new * tab or window. * @private */ speech.onVisibilityChange_ = function() { if (speech.isUiDefinitelyHidden_()) { return; } if (document.webkitHidden) { speech.stop(); } }; /** * Aborts the speech session if the UI is showing and omnibox gets focused. */ speech.onOmniboxFocused = function() { if (!speech.isUiDefinitelyHidden_()) { speech.logEvent(LOG_TYPE.ACTION_CLOSE_OVERLAY); speech.stop(); } }; /** * Change the location of this tab to the new URL. Used for query submission. * @param {!URL} The URL to navigate to. * @private */ speech.navigateToUrl_ = function(url) { window.location.href = url.href; }; /** * Submits the final spoken speech query to perform a search. * @private */ speech.submitFinalResult_ = function() { window.clearTimeout(speech.idleTimer_); if (!speech.finalResult_) { throw new Error('Submitting empty query.'); } const searchParams = new URLSearchParams(); // Add the encoded query. Getting |speech.finalResult_| needs to happen // before stopping speech. searchParams.append('q', speech.finalResult_); // Add a parameter to indicate that this request is a voice search. searchParams.append('gs_ivs', 1); // Build the query URL. const queryUrl = new URL('/search', speech.googleBaseUrl_); queryUrl.search = searchParams; speech.logEvent(LOG_TYPE.ACTION_QUERY_SUBMITTED); speech.stop(); speech.navigateToUrl_(queryUrl); }; /** * Returns the error type based on the error string received from the webkit * speech recognition API. * @param {string} error The error string received from the webkit speech * recognition API. * @return {RecognitionError} The appropriate error state from * the RecognitionError enum. * @private */ speech.getRecognitionError_ = function(error) { switch (error) { case 'aborted': return RecognitionError.ABORTED; case 'audio-capture': return RecognitionError.AUDIO_CAPTURE; case 'bad-grammar': return RecognitionError.BAD_GRAMMAR; case 'language-not-supported': return RecognitionError.LANGUAGE_NOT_SUPPORTED; case 'network': return RecognitionError.NETWORK; case 'no-speech': return RecognitionError.NO_SPEECH; case 'not-allowed': return RecognitionError.NOT_ALLOWED; case 'service-not-allowed': return RecognitionError.SERVICE_NOT_ALLOWED; default: return RecognitionError.OTHER; } }; /** * Returns a timeout based on the error received from the webkit speech * recognition API. * @param {RecognitionError} error An error from the RecognitionError enum. * @return {number} The appropriate timeout duration for displaying the error. * @private */ speech.getRecognitionErrorTimeout_ = function(error) { switch (error) { case RecognitionError.AUDIO_CAPTURE: case RecognitionError.NO_SPEECH: case RecognitionError.NOT_ALLOWED: case RecognitionError.SERVICE_NOT_ALLOWED: case RecognitionError.NO_MATCH: return speech.ERROR_TIMEOUT_LONG_MS_; default: return speech.ERROR_TIMEOUT_SHORT_MS_; } }; /** * Resets the idle state timeout. * @param {number} duration The duration after which to close the UI. * @private */ speech.resetIdleTimer_ = function(duration) { window.clearTimeout(speech.idleTimer_); speech.idleTimer_ = window.setTimeout(speech.onIdleTimeout_, duration); }; /** * Resets the idle error state timeout. * @param {number} duration The duration after which to close the UI during an * error state. * @private */ speech.resetErrorTimer_ = function(duration) { window.clearTimeout(speech.errorTimer_); speech.errorTimer_ = window.setTimeout(speech.stop, duration); }; /** * Check to see if the speech recognition interface is running, and has * received any results. * @return {boolean} True, if the speech recognition interface is running, * and has received any results. */ speech.hasReceivedResults = function() { return speech.currentState_ == speech.State_.RESULT_RECEIVED; }; /** * Check to see if the speech recognition interface is running. * @return {boolean} True, if the speech recognition interface is running. */ speech.isRecognizing = function() { switch (speech.currentState_) { case speech.State_.STARTED: case speech.State_.AUDIO_RECEIVED: case speech.State_.SPEECH_RECEIVED: case speech.State_.RESULT_RECEIVED: return true; } return false; }; /** * Check if the controller is in a state where the UI is definitely hidden. * Since we show the UI for a few seconds after we receive an error from the * API, we need a separate definition to |speech.isRecognizing()| to indicate * when the UI is hidden. Note: that if this function * returns false, it might not necessarily mean that the UI is visible. * @return {boolean} True if the UI is hidden. * @private */ speech.isUiDefinitelyHidden_ = function() { switch (speech.currentState_) { case speech.State_.READY: case speech.State_.UNINITIALIZED: return true; } return false; }; /** * Handles click events during speech recognition. * @param {boolean} shouldSubmit True if a query should be submitted. * @param {boolean} shouldRetry True if the interface should be restarted. * @param {boolean} navigatingAway True if the browser is navigating away * from the NTP. * @private */ speech.onClick_ = function(shouldSubmit, shouldRetry, navigatingAway) { if (speech.finalResult_ && shouldSubmit) { speech.submitFinalResult_(); } else if (speech.currentState_ == speech.State_.STOPPED && shouldRetry) { speech.reset_(); speech.start(); } else if (speech.currentState_ == speech.State_.STOPPED && navigatingAway) { // If the user clicks on a "Learn more" or "Details" support page link // from an error message, do nothing, and let Chrome navigate to that page. } else { speech.logEvent(LOG_TYPE.ACTION_CLOSE_OVERLAY); speech.stop(); } }; /* TEXT VIEW */ /** * Provides methods for styling and animating the text areas * left of the microphone button. */ let text = {}; /** * ID for the "Try Again" link shown in error output. * @const */ text.RETRY_LINK_ID = 'voice-retry-link'; /** * ID for the Voice Search support site link shown in error output. * @const */ text.SUPPORT_LINK_ID = 'voice-support-link'; /** * Class for the links shown in error output. * @const @private */ text.ERROR_LINK_CLASS_ = 'voice-text-link'; /** * Class name for the speech recognition result output area. * @const @private */ text.TEXT_AREA_CLASS_ = 'voice-text'; /** * Class name for the "Listening..." text animation. * @const @private */ text.LISTENING_ANIMATION_CLASS_ = 'listening-animation'; /** * ID of the final / high confidence speech recognition results element. * @const @private */ text.FINAL_TEXT_AREA_ID_ = 'voice-text-f'; /** * ID of the interim / low confidence speech recognition results element. * @const @private */ text.INTERIM_TEXT_AREA_ID_ = 'voice-text-i'; /** * The line height of the speech recognition results text. * @const @private */ text.LINE_HEIGHT_ = 1.2; /** * Font size in the full page view in pixels. * @const @private */ text.FONT_SIZE_ = 32; /** * Delay in milliseconds before showing the initializing message. * @const @private */ text.INITIALIZING_TIMEOUT_MS_ = 300; /** * Delay in milliseconds before showing the listening message. * @const @private */ text.LISTENING_TIMEOUT_MS_ = 2000; /** * Base link target for help regarding voice search. To be appended * with a locale string for proper target site localization. * @const @private */ text.SUPPORT_LINK_BASE_ = 'https://support.google.com/chrome/?p=ui_voice_search&hl='; /** * The final / high confidence speech recognition result element. * @private {Element} */ text.final_; /** * The interim / low confidence speech recognition result element. * @private {Element} */ text.interim_; /** * Stores the ID of the initializing message timer. * @private {number} */ text.initializingTimer_; /** * Stores the ID of the listening message timer. * @private {number} */ text.listeningTimer_; /** * Finds the text view elements. */ text.init = function() { text.final_ = $(text.FINAL_TEXT_AREA_ID_); text.interim_ = $(text.INTERIM_TEXT_AREA_ID_); text.clear(); }; /** * Updates the text elements with new recognition results. * @param {!string} interimText Low confidence speech recognition result text. * @param {!string} opt_finalText High confidence speech recognition result * text, defaults to an empty string. */ text.updateTextArea = function(interimText, opt_finalText = '') { window.clearTimeout(text.initializingTimer_); text.clearListeningTimeout(); text.interim_.textContent = interimText; text.final_.textContent = opt_finalText; text.interim_.className = text.final_.className = text.getTextClassName_(); }; /** * Sets the text view to the initializing state. The initializing message * shown while waiting for permission is not displayed immediately, but after * a short timeout. The reason for this is that the "Waiting..." message would * still appear ("blink") every time a user opens Voice Search, even if they * have already granted and persisted microphone permission for the NTP, * and could therefore directly proceed to the "Speak now" message. */ text.showInitializingMessage = function() { text.interim_.textContent = ''; text.final_.textContent = ''; const displayMessage = function() { if (text.interim_.textContent == '') { text.updateTextArea(speech.messages.waiting); } }; text.initializingTimer_ = window.setTimeout(displayMessage, text.INITIALIZING_TIMEOUT_MS_); }; /** * Sets the text view to the ready state. */ text.showReadyMessage = function() { window.clearTimeout(text.initializingTimer_); text.clearListeningTimeout(); text.updateTextArea(speech.messages.ready); text.startListeningMessageAnimation_(); }; /** * Display an error message in the text area for the given error. * @param {RecognitionError} error The error that occured. */ text.showErrorMessage = function(error) { text.updateTextArea(text.getErrorMessage_(error)); const linkElement = text.getErrorLink_(error); // Setting textContent removes all children (no need to clear link elements). if (!!linkElement) { text.interim_.textContent += ' '; text.interim_.appendChild(linkElement); } }; /** * Returns an error message based on the error. * @param {RecognitionError} error The error that occured. * @private */ text.getErrorMessage_ = function(error) { switch (error) { case RecognitionError.NO_MATCH: return speech.messages.noTranslation; case RecognitionError.NO_SPEECH: return speech.messages.noVoice; case RecognitionError.AUDIO_CAPTURE: return speech.messages.audioError; case RecognitionError.NETWORK: return speech.messages.networkError; case RecognitionError.NOT_ALLOWED: case RecognitionError.SERVICE_NOT_ALLOWED: return speech.messages.permissionError; case RecognitionError.LANGUAGE_NOT_SUPPORTED: return speech.messages.languageError; default: return speech.messages.otherError; } }; /** * Returns an error message help link based on the error. * @param {RecognitionError} error The error that occured. * @private */ text.getErrorLink_ = function(error) { let linkElement = document.createElement('a'); linkElement.className = text.ERROR_LINK_CLASS_; switch (error) { case RecognitionError.NO_MATCH: linkElement.id = text.RETRY_LINK_ID; linkElement.textContent = speech.messages.tryAgain; // When clicked, |view.onWindowClick_| gets called. return linkElement; case RecognitionError.NO_SPEECH: case RecognitionError.AUDIO_CAPTURE: linkElement.id = text.SUPPORT_LINK_ID; linkElement.href = text.SUPPORT_LINK_BASE_ + getChromeUILanguage(); linkElement.textContent = speech.messages.learnMore; linkElement.target = '_blank'; return linkElement; case RecognitionError.NOT_ALLOWED: case RecognitionError.SERVICE_NOT_ALLOWED: linkElement.id = text.SUPPORT_LINK_ID; linkElement.href = text.SUPPORT_LINK_BASE_ + getChromeUILanguage(); linkElement.textContent = speech.messages.details; linkElement.target = '_blank'; return linkElement; default: return null; } }; /** * Clears the text elements. */ text.clear = function() { text.updateTextArea(''); text.clearListeningTimeout(); window.clearTimeout(text.initializingTimer_); text.interim_.className = text.TEXT_AREA_CLASS_; text.final_.className = text.TEXT_AREA_CLASS_; }; /** * Cancels listening message display. */ text.clearListeningTimeout = function() { window.clearTimeout(text.listeningTimer_); }; /** * Determines the class name of the text output Elements. * @return {string} The class name. * @private */ text.getTextClassName_ = function() { // Shift up for every line. const oneLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ + 1; const twoLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 2 + 1; const threeLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 3 + 1; const fourLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 4 + 1; const height = text.interim_.scrollHeight; let className = text.TEXT_AREA_CLASS_; if (height > fourLineHeight) { className += ' voice-text-5l'; } else if (height > threeLineHeight) { className += ' voice-text-4l'; } else if (height > twoLineHeight) { className += ' voice-text-3l'; } else if (height > oneLineHeight) { className += ' voice-text-2l'; } return className; }; /** * Displays the listening message animation after the ready message has been * shown for |text.LISTENING_TIMEOUT_MS_| milliseconds without further user * action. * @private */ text.startListeningMessageAnimation_ = function() { const animateListeningText = function() { // If speech is active with no results yet, show the message and animation. if (speech.isRecognizing() && !speech.hasReceivedResults()) { text.updateTextArea(speech.messages.listening); text.interim_.classList.add(text.LISTENING_ANIMATION_CLASS_); } }; text.listeningTimer_ = window.setTimeout(animateListeningText, text.LISTENING_TIMEOUT_MS_); }; /* END TEXT VIEW */ /* MICROPHONE VIEW */ /** * Provides methods for animating the microphone button and icon * on the Voice Search full screen overlay. */ let microphone = {}; /** * ID for the button Element. * @const */ microphone.RED_BUTTON_ID = 'voice-button'; /** * ID for the level animations Element that indicates input volume. * @const @private */ microphone.LEVEL_ID_ = 'voice-level'; /** * ID for the container of the microphone, red button and level animations. * @const @private */ microphone.CONTAINER_ID_ = 'voice-button-container'; /** * The minimum transform scale for the volume rings. * @const @private */ microphone.LEVEL_SCALE_MINIMUM_ = 0.5; /** * The range of the transform scale for the volume rings. * @const @private */ microphone.LEVEL_SCALE_RANGE_ = 0.55; /** * The minimum transition time (in milliseconds) for the volume rings. * @const @private */ microphone.LEVEL_TIME_STEP_MINIMUM_ = 170; /** * The range of the transition time for the volume rings. * @const @private */ microphone.LEVEL_TIME_STEP_RANGE_ = 10; /** * The button with the microphone icon. * @private {Element} */ microphone.button_; /** * The voice level element that is displayed when the user starts speaking. * @private {Element} */ microphone.level_; /** * Variable to indicate whether level animations are underway. * @private {boolean} */ microphone.isLevelAnimating_ = false; /** * Creates/finds the output elements for the microphone rendering and animation. */ microphone.init = function() { // Get the button element and microphone container. microphone.button_ = $(microphone.RED_BUTTON_ID); // Get the animation elements. microphone.level_ = $(microphone.LEVEL_ID_); }; /** * Starts the volume circles animations, if it has not started yet. */ microphone.startInputAnimation = function() { if (!microphone.isLevelAnimating_) { microphone.isLevelAnimating_ = true; microphone.runLevelAnimation_(); } }; /** * Stops the volume circles animations. */ microphone.stopInputAnimation = function() { microphone.isLevelAnimating_ = false; }; /** * Runs the volume level animation. * @private */ microphone.runLevelAnimation_ = function() { if (!microphone.isLevelAnimating_) { microphone.level_.style.removeProperty('opacity'); microphone.level_.style.removeProperty('transition'); microphone.level_.style.removeProperty('transform'); return; } const scale = microphone.LEVEL_SCALE_MINIMUM_ + Math.random() * microphone.LEVEL_SCALE_RANGE_; const timeStep = Math.round( microphone.LEVEL_TIME_STEP_MINIMUM_ + Math.random() * microphone.LEVEL_TIME_STEP_RANGE_); microphone.level_.style.setProperty( 'transition', 'transform ' + timeStep + 'ms ease-in-out'); microphone.level_.style.setProperty('transform', 'scale(' + scale + ')'); window.setTimeout(microphone.runLevelAnimation_, timeStep); }; /* END MICROPHONE VIEW */ /* VIEW */ /** * Provides methods for manipulating and animating the Voice Search * full screen overlay. */ let view = {}; /** * Class name of the speech recognition interface on the homepage. * @const @private */ view.OVERLAY_CLASS_ = 'overlay'; /** * Class name of the speech recognition interface when it is hidden on the * homepage. * @const @private */ view.OVERLAY_HIDDEN_CLASS_ = 'overlay-hidden'; /** * ID for the speech output background. * @const @private */ view.BACKGROUND_ID_ = 'voice-overlay'; /** * ID for the speech output container. * @const @private */ view.CONTAINER_ID_ = 'voice-outer'; /** * Class name used to modify the UI to the 'listening' state. * @const @private */ view.MICROPHONE_LISTENING_CLASS_ = 'outer voice-ml'; /** * Class name used to modify the UI to the 'receiving speech' state. * @const @private */ view.RECEIVING_SPEECH_CLASS_ = 'outer voice-rs'; /** * Class name used to modify the UI to the 'error received' state. * @const @private */ view.ERROR_RECEIVED_CLASS_ = 'outer voice-er'; /** * Class name used to modify the UI to the inactive state. * @const @private */ view.INACTIVE_CLASS_ = 'outer'; /** * Background element and container of all other elements. * @private {Element} */ view.background_; /** * The container used to position the microphone and text output area. * @private {Element} */ view.container_; /** * True if the the last error message shown was for the 'no-match' error. * @private {boolean} */ view.isNoMatchShown_ = false; /** * True if the UI elements are visible. * @private {boolean} */ view.isVisible_ = false; /** * The function to call when there is a click event. * @private {Function} */ view.onClick_; /** * Displays the UI. */ view.show = function() { if (!view.isVisible_) { text.showInitializingMessage(); view.showView_(); window.addEventListener('click', view.onWindowClick_, false); } }; /** * Sets the output area text to listening. This should only be called when * the Web Speech API starts receiving audio input (i.e., onaudiostart). */ view.setReadyForSpeech = function() { if (view.isVisible_) { view.container_.className = view.MICROPHONE_LISTENING_CLASS_; text.showReadyMessage(); } }; /** * Shows the pulsing animation emanating from the microphone. This should only * be called when the Web Speech API starts receiving speech input (i.e., * |onspeechstart|). Do note that this may also be run when the Web Speech API * is receiving speech recognition results (|onresult|), because |onspeechstart| * may not have been called. */ view.setReceivingSpeech = function() { if (view.isVisible_) { view.container_.className = view.RECEIVING_SPEECH_CLASS_; microphone.startInputAnimation(); text.clearListeningTimeout(); } }; /** * Updates the speech recognition results output with the latest results. * @param {string} interimResultText Low confidence recognition text (grey). * @param {string} finalResultText High confidence recognition text (black). */ view.updateSpeechResult = function(interimResultText, finalResultText) { if (view.isVisible_) { // If the Web Speech API is receiving speech recognition results // (|onresult|) and |onspeechstart| has not been called. if (view.container_.className != view.RECEIVING_SPEECH_CLASS_) { view.setReceivingSpeech(); } text.updateTextArea(interimResultText, finalResultText); } }; /** * Hides the UI and stops animations. */ view.hide = function() { window.removeEventListener('click', view.onWindowClick_, false); view.stopMicrophoneAnimations_(); view.hideView_(); view.isNoMatchShown_ = false; text.clear(); }; /** * Find the page elements that will be used to render the speech recognition * interface area. * @param {Function} onClick The function to call when there is a click event * in the window. */ view.init = function(onClick) { view.onClick_ = onClick; view.background_ = $(view.BACKGROUND_ID_); view.container_ = $(view.CONTAINER_ID_); text.init(); microphone.init(); }; /** * Displays an error message and stops animations. * @param {RecognitionError} error The error type. */ view.showError = function(error) { view.container_.className = view.ERROR_RECEIVED_CLASS_; text.showErrorMessage(error); view.stopMicrophoneAnimations_(); view.isNoMatchShown_ = (error == RecognitionError.NO_MATCH); }; /** * Makes the view visible. * @private */ view.showView_ = function() { if (!view.isVisible_) { view.background_.hidden = false; view.showFullPage_(); view.isVisible_ = true; } }; /** * Displays the full page view, animating from the hidden state to the visible * state. * @private */ view.showFullPage_ = function() { view.background_.className = view.OVERLAY_HIDDEN_CLASS_; view.background_.className = view.OVERLAY_CLASS_; }; /** * Hides the view. * @private */ view.hideView_ = function() { view.background_.className = view.OVERLAY_HIDDEN_CLASS_; view.container_.className = view.INACTIVE_CLASS_; view.background_.removeAttribute('style'); view.background_.hidden = true; view.isVisible_ = false; }; /** * Stops the animations in the microphone view. * @private */ view.stopMicrophoneAnimations_ = function() { microphone.stopInputAnimation(); }; /** * Makes sure that a click anywhere closes the UI when it is active. * @param {!MouseEvent} event The click event. * @private */ view.onWindowClick_ = function(event) { if (!view.isVisible_) { return; } const retryLinkClicked = event.target.id === text.RETRY_LINK_ID; const supportLinkClicked = event.target.id === text.SUPPORT_LINK_ID; const micIconClicked = event.target.id === microphone.RED_BUTTON_ID; const submitQuery = micIconClicked && !view.isNoMatchShown_; const shouldRetry = retryLinkClicked || (micIconClicked && view.isNoMatchShown_); const navigatingAway = supportLinkClicked; if (shouldRetry) { if (micIconClicked) { speech.logEvent(LOG_TYPE.ACTION_TRY_AGAIN_MIC_BUTTON); } else if (retryLinkClicked) { speech.logEvent(LOG_TYPE.ACTION_TRY_AGAIN_LINK); } } if (supportLinkClicked) { speech.logEvent(LOG_TYPE.ACTION_SUPPORT_LINK_CLICKED); } view.onClick_(submitQuery, shouldRetry, navigatingAway); }; /* END VIEW */ Mj0 s RE&[۵!o$PDHaO?"WFDMd{ 6(|]?"Bc59Z1rPTX'շq@e*L2LT{u;t@j~EVLEm3` 3mY&,ʞ*o66jDLE?u?yrIe:*h'ȧ:z7%r@`5wk^`gJwme5T k2O9)헝 =2Ǚ*faO_jQ,WD!Ȣ ']K:.CQ~ ? /* 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 { height: 100%; width: 100%; } a { height: 100%; line-height: 117%; overflow: hidden; text-align: center; /* Can be overridden in JS. */ text-overflow: ellipsis; /* Can be overridden in JS. */ white-space: nowrap; /* Can be overridden in JS. */ } a.multiline { text-overflow: clip; white-space: pre-wrap; word-wrap: break-word; } a:focus { outline: none; /* Remove outline from tabIndex = -1. */ } // 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 Rendering for iframed most visited titles. */ window.addEventListener('DOMContentLoaded', function() { 'use strict'; fillMostVisited(window.location, function(params, data) { document.body.appendChild(createMostVisitedLink( params, data.url, data.title, data.title, data.direction)); }); }); /* 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. */ body { height: 100%; position: absolute; width: 100%; } a { height: 100%; position: relative; width: 100%; } a:focus { outline: none; /* Remove outline from tabIndex = -1. */ } div { bottom: 24px; margin: 0 7px; overflow: hidden; position: absolute; text-align: center; text-overflow: ellipsis; white-space: nowrap; width: 90%; } span.blocker { display: inline-block; height: 100%; position: absolute; width: 100%; } img.thumbnail { height: auto; min-height: 100%; width: 100%; } img.large-icon { -webkit-clip-path: inset(0 0 0 0 round 4px); height: 48px; left: 50%; margin-left: -24px; margin-top: -24px; position: absolute; top: 50%; width: 48px; } // 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 Rendering for iframed most visited thumbnails. */ window.addEventListener('DOMContentLoaded', function() { 'use strict'; fillMostVisited(document.location, function(params, data) { function displayLink(link) { document.body.appendChild(link); window.parent.postMessage('linkDisplayed', '{{ORIGIN}}'); } function showDomainElement() { var link = createMostVisitedLink( params, data.url, data.title, undefined, data.direction); var domain = document.createElement('div'); domain.textContent = data.domain; link.appendChild(domain); displayLink(link); } // Called on intentionally empty tiles for which the visuals are handled // externally by the page itself. function showEmptyTile() { displayLink(createMostVisitedLink( params, data.url, data.title, undefined, data.direction)); } // Creates and adds an image. function createThumbnail(src, imageClass) { var image = document.createElement('img'); if (imageClass) { image.classList.add(imageClass); } image.onload = function() { var link = createMostVisitedLink( params, data.url, data.title, undefined, data.direction); // Use blocker to prevent context menu from showing image-related items. var blocker = document.createElement('span'); blocker.className = 'blocker'; link.appendChild(blocker); link.appendChild(image); displayLink(link); }; image.onerror = function() { // If no external thumbnail fallback (etfb), and have domain. if (!params.etfb && data.domain) { showDomainElement(); } else { showEmptyTile(); } }; image.src = src; } if (data.dummy) { showEmptyTile(); } else if (data.thumbnailUrl) { createThumbnail(data.thumbnailUrl, 'thumbnail'); } else if (data.domain) { showDomainElement(); } else { showEmptyTile(); } }); }); // 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 Utilities for rendering most visited thumbnails and titles. */ // Don't remove; see crbug.com/678778. // // 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 Helpers for validating parameters to chrome-search:// iframes. */ /** * Converts an RGB color number to a hex color string if valid. * @param {number} color A 6-digit hex RGB color code as a number. * @return {?string} A CSS representation of the color or null if invalid. */ function convertToHexColor(color) { // Color must be a number, finite, with no fractional part, in the correct // range for an RGB hex color. if (isFinite(color) && Math.floor(color) == color && color >= 0 && color <= 0xffffff) { var hexColor = color.toString(16); // Pads with initial zeros and # (e.g. for 'ff' yields '#0000ff'). return '#000000'.substr(0, 7 - hexColor.length) + hexColor; } return null; } /** * Validates a RGBA color component. It must be a number between 0 and 255. * @param {number} component An RGBA component. * @return {boolean} True if the component is valid. */ function isValidRBGAComponent(component) { return isFinite(component) && component >= 0 && component <= 255; } /** * Converts an Array of color components into RGBA format "rgba(R,G,B,A)". * @param {Array} rgbaColor Array of rgba color components. * @return {?string} CSS color in RGBA format or null if invalid. */ function convertArrayToRGBAColor(rgbaColor) { // Array must contain 4 valid components. if (rgbaColor instanceof Array && rgbaColor.length === 4 && isValidRBGAComponent(rgbaColor[0]) && isValidRBGAComponent(rgbaColor[1]) && isValidRBGAComponent(rgbaColor[2]) && isValidRBGAComponent(rgbaColor[3])) { return 'rgba(' + rgbaColor[0] + ',' + rgbaColor[1] + ',' + rgbaColor[2] + ',' + rgbaColor[3] / 255 + ')'; } return null; } /** * The different types of events that are logged from the NTP. The multi-iframe * version of the NTP does *not* actually log any statistics anymore; this is * only required as a workaround for crbug.com/698675. * Note: Keep in sync with common/ntp_logging_events.h * @enum {number} * @const */ var NTP_LOGGING_EVENT_TYPE = { NTP_ALL_TILES_RECEIVED: 12, }; /** * The origin of this request. * @const {string} */ var DOMAIN_ORIGIN = '{{ORIGIN}}'; /** * Parses query parameters from Location. * @param {string} location The URL to generate the CSS url for. * @return {Object} Dictionary containing name value pairs for URL. */ function parseQueryParams(location) { var params = Object.create(null); var query = location.search.substring(1); var vars = query.split('&'); for (var i = 0; i < vars.length; i++) { var pair = vars[i].split('='); var k = decodeURIComponent(pair[0]); if (k in params) { // Duplicate parameters are not allowed to prevent attackers who can // append things to |location| from getting their parameter values to // override legitimate ones. return Object.create(null); } else { params[k] = decodeURIComponent(pair[1]); } } return params; } /** * Creates a new most visited link element. * @param {Object} params URL parameters containing styles for the link. * @param {string} href The destination for the link. * @param {string} title The title for the link. * @param {string|undefined} text The text for the link or none. * @param {string|undefined} direction The text direction. * @return {HTMLAnchorElement} A new link element. */ function createMostVisitedLink(params, href, title, text, direction) { var styles = getMostVisitedStyles(params, !!text); var link = document.createElement('a'); link.style.color = styles.color; link.style.fontSize = styles.fontSize + 'px'; if (styles.fontFamily) link.style.fontFamily = styles.fontFamily; if (styles.textAlign) link.style.textAlign = styles.textAlign; if (styles.textFadePos) { var dir = /^rtl$/i.test(direction) ? 'to left' : 'to right'; // The fading length in pixels is passed by the caller. var mask = 'linear-gradient(' + dir + ', rgba(0,0,0,1), rgba(0,0,0,1) ' + styles.textFadePos + 'px, rgba(0,0,0,0))'; link.style.textOverflow = 'clip'; link.style.webkitMask = mask; } if (styles.numTitleLines && styles.numTitleLines > 1) { link.classList.add('multiline'); } link.href = href; link.title = title; link.target = '_top'; // Include links in the tab order. The tabIndex is necessary for // accessibility. link.tabIndex = '0'; if (text) { // Wrap text with span so ellipsis will appear at the end of multiline. var spanWrap = document.createElement('span'); spanWrap.textContent = text; link.appendChild(spanWrap); } link.addEventListener('focus', function() { window.parent.postMessage('linkFocused', DOMAIN_ORIGIN); }); link.addEventListener('blur', function() { window.parent.postMessage('linkBlurred', DOMAIN_ORIGIN); }); var navigateFunction = function handleNavigation(e) { var isServerSuggestion = 'url' in params; // Ping are only populated for server-side suggestions, never for MV. if (isServerSuggestion && params.ping) { generatePing(DOMAIN_ORIGIN + params.ping); } // Follow normally, so transition type will be LINK. }; link.addEventListener('click', navigateFunction); link.addEventListener('keydown', function(event) { if (event.keyCode == 46 /* DELETE */ || event.keyCode == 8 /* BACKSPACE */) { event.preventDefault(); window.parent.postMessage('tileBlacklisted,' + params.pos, DOMAIN_ORIGIN); } else if ( event.keyCode == 13 /* ENTER */ || event.keyCode == 32 /* SPACE */) { // Event target is the tag. Send a click event on it, which will // trigger the 'click' event registered above. event.preventDefault(); event.target.click(); } }); return link; } /** * Returns the color to display string with, depending on whether title is * displayed, the current theme, and URL parameters. * @param {Object} params URL parameters specifying style. * @param {boolean} isTitle if the style is for the Most Visited Title. * @return {string} The color to use, in "rgba(#,#,#,#)" format. */ function getTextColor(params, isTitle) { // 'RRGGBBAA' color format overrides everything. if ('c' in params && params.c.match(/^[0-9A-Fa-f]{8}$/)) { // Extract the 4 pairs of hex digits, map to number, then form rgba(). var t = params.c.match(/(..)(..)(..)(..)/).slice(1).map(function(s) { return parseInt(s, 16); }); return 'rgba(' + t[0] + ',' + t[1] + ',' + t[2] + ',' + t[3] / 255 + ')'; } // For backward compatibility with server-side NTP, look at themes directly // and use param.c for non-title or as fallback. var apiHandle = chrome.embeddedSearch.newTabPage; var themeInfo = apiHandle.themeBackgroundInfo; var c = '#777'; if (isTitle && themeInfo && !themeInfo.usingDefaultTheme) { // Read from theme directly c = convertArrayToRGBAColor(themeInfo.textColorRgba) || c; } else if ('c' in params) { c = convertToHexColor(parseInt(params.c, 16)) || c; } return c; } /** * Decodes most visited styles from URL parameters. * - c: A hexadecimal number interpreted as a hex color code. * - f: font-family. * - fs: font-size as a number in pixels. * - ta: text-align property, as a string. * - tf: text fade starting position, in pixels. * - ntl: number of lines in the title. * @param {Object} params URL parameters specifying style. * @param {boolean} isTitle if the style is for the Most Visited Title. * @return {Object} Styles suitable for CSS interpolation. */ function getMostVisitedStyles(params, isTitle) { var styles = { color: getTextColor(params, isTitle), // Handles 'c' in params. fontFamily: '', fontSize: 11 }; if ('f' in params && /^[-0-9a-zA-Z ,]+$/.test(params.f)) styles.fontFamily = params.f; if ('fs' in params && isFinite(parseInt(params.fs, 10))) styles.fontSize = parseInt(params.fs, 10); if ('ta' in params && /^[-0-9a-zA-Z ,]+$/.test(params.ta)) styles.textAlign = params.ta; if ('tf' in params) { var tf = parseInt(params.tf, 10); if (isFinite(tf)) styles.textFadePos = tf; } if ('ntl' in params) { var ntl = parseInt(params.ntl, 10); if (isFinite(ntl)) styles.numTitleLines = ntl; } return styles; } /** * @param {string} location A location containing URL parameters. * @param {function(Object, Object)} fill A function called with styles and * data to fill. */ function fillMostVisited(location, fill) { var params = parseQueryParams(location); params.rid = parseInt(params.rid, 10); if (!isFinite(params.rid) && !params.url) return; var data; if (params.url) { // Means that the suggestion data comes from the server. Create data object. data = { url: params.url, thumbnailUrl: params.tu || '', title: params.ti || '', direction: params.di || '', domain: params.dom || '' }; } else { data = chrome.embeddedSearch.newTabPage.getMostVisitedItemData(params.rid); if (!data) return; } if (isFinite(params.dummy) && parseInt(params.dummy, 10)) { data.dummy = true; } if (/^javascript:/i.test(data.url) || /^javascript:/i.test(data.thumbnailUrl)) return; if (data.direction) document.body.dir = data.direction; fill(params, data); } /** * Sends a POST request to ping url. * @param {string} url URL to be pinged. */ function generatePing(url) { if (navigator.sendBeacon) { navigator.sendBeacon(url); } else { // if sendBeacon is not enabled, we fallback for "a ping". var a = document.createElement('a'); a.href = '#'; a.ping = url; a.click(); } } Vmo6_q> *+ɺ7W& h\PbLIqI9qB$yx?ݶC]I{! o153<]NaqմԷpewly%^Gv7af)۴f'_y50[2Zo?Zi3ώvT'_~[9jS88XJ*4 mA.ZԠn?8h>QOiEE)]"4d$g{y5.4P8o)v, N՛shqc#IJݻYX9)z˟,Tcͼ$30]5qC-4u Iipl4F$k73FNE4bC>t+:S>C.tǑmzC=`]1Y-AKF>7(w'+WX ^魃͒.c#g~4іlq#Lu6Šgq"Ҁ WV n0 ~ tzqO]]>,1Y(:1';0 [$Hl(;'pg>zuWqLzxB0cF>+X[F2 l YrB;/*7 &N ɴ!Z#|~aZBPP YnGk|T-P}c6(nf[mO@j: sL_IeLψ9=62"xeP)(:#-ơ٫~\u:%`A>c+q6p4HL5\C]f Ηrtd.Z'Ĕetua[_޴<]s:vΕt#Q7}׻uw:l;w DBc5L_Kz>(E$H|OR2],+1GORz%.jK,4ЈRUULQBEL0.c%b(_z\%bRTL,UӪD,s1Si<iϕxu}yݕNN?t"~ki2-*] ә-U6"FtjLqk*-5(e~H`L)T^d 9̔flAj,$瀆2"K9`UsA&e]XL:L#6!uDMZ-iQUxgJ = Hz"'S@x`*`Ī$Uk3`6Onp(J]!!a/%=*g[?&Ha[kmɫ!y ,[D^gJ/0.0)hGč1oIj2 ԓS07NRBdFL2Ǝ*z2[)߾{+lqƮɆi n,XB[>}{su{o߾y[@\m_ !?z#;;Hr6|QL` 9L] F㐸:*L DN| rā/;Ĩe@KAv#n4P`.y*۴?*~Uz2iz;o^*^l љdy:FpS$ab XHmk ! pJ$0rH^.qѣI"$/|p0$y8 Szx`V{5} , |_fmà]zk]%9<ŇY^w7P)~ ) X@qpw=Ir3 `82P+s*贚Hmu5wiB#QYb ƃM\ݸ!JnR"`bߣ^:&~O! dxRJ`t:t;Zh J ^>'ԔiQϲ4vvW:}`%Ζ>g@GaylO~ΟSS3/KL\}]U*\`"G(hV}#eV{a?ۜ&yuA~G^CS;V|CO:[P*`I=HfL-.3ǸȘw^'p1 xkڲ?`yK sۧm]914ɱ[ s@}w_+[ aPIQx6@qu=Nl( B?rSf t9#uik09cL`q:Մ}mg庢:϶4 VE+Rvsw1ξ Qȥt\#"li24 L\ @ i ~?-2j #de0x{` V69jٙ3ӎe%Cc k(o9_ɂ1TU٦Nߏ'mUa@=᷃EXNOТI(8d}p|VB˞RٴZs 43YQzaѾxeyl[Vy]bSDzΫrgB|n c$<-m2Y+$U]sl (FV 5aWfýQ\Ǝȓ[k_];k n [֣jؿPh4uOX*Pɯ_%Tt@ l8t:@ ,݅߭_D)oC#8DGJpIsG->ԏ= PnpǡY}8a5tuu hI= &uls{DLnf㸉L!Rچǟ{CE?0YhX߬aG )ѣ;I; G]w~H=ˆ[+P 2WmmR1Gӳ m%`+Med("f)DC30 6]`o q0kf\2J7^A )E@x MC]"b+gw|F_NTCDscrY($~뎰uծ\=ԿTHEAٜe2o"Z|:j#eXm; U0Js['-:朻:,K.d6l+@[?s9L"6fK60.pwQMm#uS06לX &̴acCqw6)wt;jtl7i\"CQ0c!'[lE"#w2$Skl4=FԾͳ!RkhQT,LdS̞$~F?DGi3:pG{0+t'~mn4LtP3>yRVc ϕްSO߳{֕y)x ;|g!/] T5əO%<̅v•fȨy L5vkmg-ڔm{Cp&؁ 'rMm+`*(vh /`RfTmW\LweQv(Bڡ=F3hu)1;4ۛHNW\xµo;!PJ-MZofkCc~%N9< 9}')!kԫ1`JŘ:0$|6I abP(3 |ZdH3_}U+=vԯ[Ǿʋ\tb}|Ӗb,st7[,c-疱ascM<.h>ʺl{kvAX^D˴[us覽Y"7 u{N>.EO\;vklq=8cP_yds.p]|<)͌}GW;!o삢L )&ԜRR5DSpw5v[4hEw@DPM@yk Vg wC/8a_&h%f̓Oa)1G`=ėSŦuBTnz(Q@>`ljFo_ƤN~_jeC0mrZ$9HlF(w6)jm.J9@ie$S傕35t+Խ$p)m1j*j_;<|Y_pl )U0K:cACroEj {ƕ5Q=cͅ? ЋCrkɢL%ў>F%^Y7D |?ҕÕq z uxhhOi1:HeLE洠w> 8 Dq rwފ# pϴ?wƨŶ6JCT\㝻=%?үPx#^niw1Y]ϒ̓cAS)tﻜK_*1[ `hf T:xhM^窤,vPbp(vfK*Nj|[PjyAQ,NqۛUGVG@CՈjթxcocl#"3J $V(E=_]o~J> u5H}NȱYeN gڰX7M?Qz X) 37_#*[NSA#[P^Lj'xa.c墠}nO|iz=jB gQ [_`b;)P'`kѯlݥn6фOgU*Mv5CQoxKPf8㍞ǜX0u97wX6@? "i]V%I< < tF00p -D}#-"LVgkSi7g]ÐtшeSa<@Z(_<g`JO-oտEZ;GVh➘SGtJiD << q ~G CcGQ&?ǐ$i=o|MS="C@ B+8~G`ZA)dNoQ>Ѐk UdG(hȋW*#3x,\(5ti rOohs]Z)[)@6g𧃿SFWf5G 6v2L 7BiǶDZMX=xGwKaqnǪG`Ԏyo6[8ꭻnӦA[hp !ob i?F\S\`v4NJ%CvJU4ʁ$!-k3H*CS`5vóHWR*)ŒFXy,)٬QҒVeKb`.QJ3 fC؂% @jw#~(qCA;f;OUo`nZn앺-j>OuUe>rX-p-w),_'m{?eflW8憆Ul&M~t[d.8=Ʋ_a,3$_xxj)@L׃joow,jQ9}~Bˆ{ΜR$'qQBm?"ʼWJ$s ƃ16՘p(;G?"ÎqUsS/SSI[T- G0&G}B4:jВAf#13K]ͧʾahZf_ tT Aȟ. >ph18f0i3䤿Fq-4ILl(s˾7(^sN=HAuum;i%^ W@&HD LIfO-vUWZWPx.m†e8qy(msלox>_#QX/Bmx B_J4m5zn/'0L6-js(aIid$:MOx ">(Vn,Oq1LMf1o'|E-1φQpi3MCg+IA~2Uz`8:3L-'3w{;ovḢa/W\\f(&m9FHuhrW|-T)ǡK~aҸutfPSh+ْzh6Ժ\6ʦ>TFkZR[5o) &aڻf际72S31/Ǽk-*Ir+o$+Ku/J?(i1GZK(\`K!$tèCOHTɲV.*4V L]HpR]\yVo6~2K';i0qӡIS@ڗhsHyER\;Myy񻻏SÓ3cp=UU /+3J'(ҠPZ` ^#̔kвR)B*3Z*LVڬ ((L ` Jd9޾H;*JFFH ")(_|%*U r 8I<.. QdEV ȣ$%Y2%mQ.B.w[z:ʪ0|Nu^h#TJԋR25fpΚδ)g=v4766+xƌT;"B 352*yRl}:AJD[V>[&70gGgzָ^s,2rE`F`z[4XS,*Smm[%ߕYĉ%KlkSC:*lURSrʬToˏݴk_:Pnz6Of!HOC4j)a雕VR|gTߐ΁P߉8}w_ Be[./7DF)ZߎooZ-p'}@)[ q>Z{φk)M;e9]$Knw>2p{BjP} {ZS!?paӳCKPXYMNygnn gQ fhٻ![N:T>EDSvUbȄ8验\n,=)?OXz'ןw h^'x~4=$>nq}t6ݯ̶œ6/3,7N~K_OLzDKQ7v _H$-ɐDbx{=fҝoW' ;|Wߨa Y{sڸO%7W &$a@@@o["~0@'# !fl?В)#F?ͣx4%كWfIyY:13IH0yɱqK5b6+a)xQwsIļ˼K 1'nƗyˍBՋňpYL-!hO̓+Mq7I+.#R ]FZ(|ҜI7D4=(".{U0t Jb6#s&%j6Jq&]*E2b(ivZ5sb.7 %#ħÃ:)Ӈs|灱t\*J+¨NFތ!8 !!oXaFXȍsiLmki$D-Y併uC^b|xcMcyU}{Y+ӛ;jnŮZ-ߚ&Eu}wn鏛ˠc~vUЛ۳j8@cO58tzY8?|O&מּ/N;ZtZS߿uueGx}[A}ۃ٨\:4߾vz?_Ie.Cwx=eeޢ;w%앺ïgOMگNA۝ mo[;vڱ޲ZCF-se摐rVnɡx%gP;7rirѩ`CCbO0]VNqC!M=' ca x}2brwv+{ ˬ*-C7\X>7|<ec|Xmhpz$[ʺM'4PNrݨrykh0@08ȍp&zPz|-"AQc8No㙳bG{S)jXMHԄklpdZpi?OiC0ZE8j$&n=ʧ va4i|qnX%ǩ>O&oO#jώD9u]3!!ԛa)hiU)ؑe扸eDMt/J!4N]C)R?xVM r\6I#b [2μb|?Si +D`(6c7ulrO$Eyn5m)I(C},pj4B1gǐ[OH tCOnN /t|kǙ8 }G]|\fкTbmÀݽ>TMwu}=-R@yLm4X_$!E1. d85"/D_\pjg|#7lqno` $60/[VrJO1XYXoyT>u17 %Nzy-PeDPDA6U|e?| io"5#QXH7*{l20ʪwit>rDM{3:u<<%: 9[DŽisQ0bωL`.)-!"%6,P-5|c' /ɊJ=(ǶS$:EvAЎmjH\^vjroHЛRQ2P$bK ۘKZReQ@ZKFGf^ UUe2]/LxYa - 8^R$Bgckl:/VIVM)|{fW%}r^*^ 6b]yR+%>'w1=·woQ[Z*X7PwK!!kMsgapYP:W}xZ7'\De% XRWџquaif3V7}Afcxk|%Zx+7* 0+=fLhEHBHt = ;WxL]]d78[Q:v`K6DD]P윌0ڸqBǐ;x ÈJ1mRp.,k7+GC&&D.I[c F ۔Ga&iIbK'Jԑ\Pl>e<=>0B F" mfnfՙAjo ʻ!N.rp?9=OfB+~S5 ځ7!hgmD kq ΍ Rn0 w[(.C~CHLTCdE* H"ґMaU_~ʅ{>0<{dQ{1^ēG˚CJv9SfS#5٭nܬs <䝅kQ0M6ڻM/${Km@"/,_vyE%M AGKwTq=*1d72P6ls )>$#g۝8+<<2!N(mܹ%p"#2Fib(v:u`&Z'kpzQsfxG\>ٿm/ U 254Vmo6_q.9NlCuҴHOQ9"JI95IIdm}#{&*hY8=3IՊ+,lR  5qo4 Z͸ $*EGF-1\=Lf3f!a蘖)pIno&wװ^/*o才{e!˕$W)Z%h-7?{ c2Ghp{niiW\銴t&Œ`m xݘ9%z- <ӟ%[Ze`OeBk}JIjʘK#=X8[hl*C3eshqtEEL~!9bNq_S,P> ..$$DlݩUr5*Š}F)-KZN, kez\%uHcVbjE3muT'sVU :+owp(зt8/ܗAi_]U%gRwٍ-6t!Ubj+_hB7>@d'jXU&>3ƨ'jA>lJ'Od)kU!~vYwtCf \Gn~i5ʮ[K{/Nmp (rJPHy"߮DJF?7 -9j݈^4#4`ӒN{4ag0yGլw]iD7̇  )+wgY?F\j Rj@+zpfPRdI Rjw$ ^ݑcQ]IB(,̼6 F̜j\ciԷpK!pLF ;ysi(B>!}kah@'ep F đ[ _>o>CEs_f Ylj`im&:EGU^c_9=ܒ~βو2,>pd!jyu|Kcա&_btZuW+B_tR `vz ÿ[HE- ,Qiىnq9DiGuڟ*rSczmu-Y7$dKCUqPO/Fsi6+Pc#tώxCV6&` sZyS8O0'ҁ*!lo%J"P,,砫'ɎsiX~zd' #}=kmE"*fc ,\*Dиodd4_) TLT2*rL2lH+$f1xyad)S^RAbR4ׂ:`[fڂD1qPID+=}%U)L,)|ನ +p~"2(kn_o>#ij9t_J 0{O䏔%SbՂ#ؓCb`B(ysDb`}IHc0ԕY:#˛SUO@FuoĂU=Lt4 ET/";lwk߬j˻ BX}gʟ*J2#H^!$ "\h9ђС4:Q =f@[1!ܮ#R׀˰mc-2Wh`RU@SMeSfJl':4ns/M |`1&#:^&?:r.]j ^%$#SJ*ETdЪby@ʄXzt&",*x/`ja!20U#6Z: Bg[q-qTDIбKLL]6U&d-hici-g~ FXX)Gc8@[UܐJ@eU0벘CwL2)kҾY e"_f :mIc/-Wh)TdhJ'ږ >u "ӬQV|mj88ʹ\pL֓87Ϳ/n½~}h֍'<5pNQB`B Љ͡N6@)lP] O Լ)E{iQ9 is0Z]-pKKۃ?~dqu-N9ilBg.yT!+ٕ2f}b0t$) 9ؾ-IV:m0yd"rvꠅn}σxcw  fߨ)g |:TPXĺP, ibF&[0/@ƜisrtgJ>,eڂr<<`N] .ɮgN$_ ,ga.rE|̨ؒeru꘽R]t~u ŧc1Hy!'"fXjޅiEa`}S8q39VX9s0 P+Lmhvy6ZucN&YPRXKg{1==!Ά01/0-MQޱCm4&c:JGzCY cD9QZdMߟv 6i?j_Kz-mFެ]!Sb#s-~Ļg/q>]7f4i^Lv ^.A;gQZ~Wg;I~vx/ɗDAkG#|wz_" N|y<4.xc~텿n~=̛g@?Sx{?.n?]>2F?oN:>{괷qߧ'׍˳D_8g}6Gg}ʹ n<4rkQp"\b._Ɵ'b,E:'5~ɽ&q_sI3|B35GB5 q}t[?{K{K >`nv^ާu}UOf/^xa@+ '"[k'*\zv֨PG@9| sSj]f4n@c0I;κ.zLs1Ukm!m\*]. =w=UWVLʡI!]ƴ[,?tU9˸RA// Tq `=IM^RP M-hۯ‚PR $^L{RH.gVX]AćwrM!q1 (-K+7o\7W/Wn׃IPRLu wJwq H F ] h<ÏIh@Z_jyծѧťؕr9O> `/;hOhye,0k9ڌ%@#g@TgiQv-+8 Dl~3dhʙz{$z2-<س*7Wpص[ DH Mb:pWײJR0Etq- KϲcɊj(N3$836&qlZ׾ c`+{>ȆA'5S!0 ͙>;9|Z!|!hNBvw)je|Nwyx5^mfp&<[s۸N)JffNlg"gP$d쨉{ ^$9~M3D"sǹ:<z)ݢd(~ )N^R<[W ], ,E!KYtrpx(ޔR蹨^Nw^Ll#bl\VLL%2i"Db&\TKLNbʹ ZiFbΓJ|08{W|/EB5Rą*WYQ-3/d; 830ԜUQ:[/s$rTBV"fZg2u<< rAo_!'!0NR2+:~S{&w:պ?gk-Tf2t1&eWrDEa 89QsÐg=˴\&j]"gLSQMy eY輬5bfR| %t*DRMi_*VVkZ|9n1c `\%Zh8sy D4)tYUjD*{# FLj&BWڬpeFTq̽ObHt0D 2lUչ8?4}RGjH>?#G![5<4a4-d+d}<Ϲn2z5&GG{Rwo;%Ӄ6Q1&@)B F H'½JlqQt Vb*O8j\9$a-3k){ ^=3 78۽qn&OcQy 90.TqNȄ?&Y&L/qTRיbbO/[0F_$\^Z߁_oq pд481SI!;^4@,0]4bU{L_Zn͆HLܖeE5[Ďu7/"{EMi]rBR֠qA}C:gy b ѳߞohv4(Βq8Sjm|d:EUʣT&;SA#6tQבOO[ muR/e\`hX3ܖ lK%WV m&eF ?[ĥIAqNieqn]DS[uZ #qێ `$ED(G[;sހ A\G8ћãf5pRB$+Ɵ]`C0.b{ԫ&  oZ -LZj `*BlKYs$z5&".8=UXڝx rzM$뢐xX`|T1-7*a͈ۭxKBP lv~j$;16G^`z ,bRنcòN,rW8xV鰳CUq=dL==zrz{~y{q緿===5:yŻ#ʆ X o_Jt3b]0jCC dIjT~<`kt=&'+f`X{P $~Gn/ɤ9^s`7?aJ$<™ L΋W '8OcNgksIS<`M\etyMy&BםӒE tVhIBhdZm]2'rEQC,F|} S7̃A %;FZq )|Blm+0]P.?&ǎ2,=hlo n^Ny_ގWPF0պ\ LO)sb/ٺ2"jPT"SX㵄C_S}dNw`l?;؊4ll N>]a9M߿]Dh'+,4(7n*q& =[kU]1 mݚ9>Φ_{c3;Ck`+bl4w Hh˯W$F/fK” hr9)s#w-Ԭog21?w&Y.Y*vpP5ؓ(q}$f$5hA|5th+?52R.bvpطi.ݶ̸ٍl'GZ^ѭiRn^~#yx{Gd#}n&|0Y1rw \i+,xqfĞ*4.\33Y=`=sV ,>D5WWBj_ZhqfBc9C{fX4|vv4YyE'C! >@&ډc)5/l8l]x ,sRv#avy}c=OvVب+ ;G}}[.O=3*%K%"h|l5:|pcmaY5\mW=ȅڥG0ij[!m-c`dT1эy#5'qHL MrX 6Y@cM;BrW$*_Ab1+޶Xkh8)"#d67c E% _qun@?A_x_N+ΠPq$aκ5+$fleux{ cBIlw.b :^]T`r|]uDbq 4k*&,x]M9MgPv_y+q~Z 8g ~#Sq f3D;;|"8cKx&ͤJ} R(!ɵ[ljtecx&[>0uMr5愍8eRUcŶ|;!f at2 #KX6 ] A>@rkTތoڊZ3mm=7ʆ;u*_[ԓ29 a!vU +6DM. ^H3hG#FV85XwESl`}-~$Ҽ߷^Ha99y'~ #a4o@߼." nB>0&F(4- W%8%M^Z&L}8] qC:<5 {wt\$[3crq/&1"#qMn!XK|/>au {oM W{[Rv-Ԡ a_=&a|۶l=NT;jAyjڿ]++~Qn ]{qNƨS7z=Eɇk(QThODړݗM1r LvZ}VFnMBG}߃ f~:Ļ+p^s]3$3Wtopsο*e(Ig \5c' Cu{^WSI78 }׿l ^noG{VW_0m|Ʌn$#b.yeyl3Fw4Pvisw,4y#NmKpQ3p*G>x?*E6! Z:p%BiYZ#pBPDMaJ1 5#?>TlVqglVzp[IM?!kyΣ9,O6Z.UY8o(G%O4=LkPoLjNjmQ4R:m4ƈ7ꋐÕ+#N_ax@e^, h_K̵M%qٳzYiR|6piϸ/xPpir[>R@𘨯kf6M+~waݛ |7,Pv)2Wj\qڰIt?)y2'Q7WItrb\\ڴ44Idsƒ.JQׅD<4jYULx;m6s~!k)z"lNsx4<̤ћN"&TPz1bRLkJac>U#F嶲JD;8ibG W&F2%2L?'*F+}2?pGpCz,ҽp1#َ.508->Z z9mRuҽR7UE a,Zj܍gԞk#&e]+4 fONG{/O=PX[FM6j'9!I'i>u|0R-~X.Pt4oz;%<ٖ4j=r#1T]V:_CIX.~Mc}̞WZ Z{S8O $U .f؛(Vm,9-ɎVr׭O"adpH~_ySlFBo1{!#1E?d_91X6lTՈ/bb @Mr9ediHcRW!X0k[[$ea&"dr蘤lԯTDhL,l~sYPT_ɉH)OwIm̈́dYD350$P~0{ ¶ȯ/Ɉ5rŔ1 >uZBb`SOc14Y:#ÓȈN~mlmy"Xo[83=pD4dTuIW_"tt{`֓P<(, Ҁ7Be;U?d 3H_!DC\:#&ѹsS22qȕ? A CNČc"3I~D+W.w)^Ejm7O?n\(Ie?RZtMT+ЍԹt&.mEb4]h:7?CA^\Vg?GX- RZGѐ$@ ڽ!q,tu!f-A&9PJD@VוH:OV6L"MqJN&\;,}lq#_n~lT AO69 >Kgi fh)A0ÃRb% #(H9!1e.ȌATLiAf)h!A# 1:) LY48Bha!2U6t cZĖM XJ"bmIBFc=0 Yľ" ];m:TgBr yZڜiT\*4 +QB|tx9YdgB5 T_ھ .g-N;:DGk. ПQ F'Kf. ^nn{7tڒX$$T?qF쭒'VJW/>kuQO0SlͯN`8`+l+L]rk5fJIYmgY VԚR^ѤJRbFf SRFIRp2E ЉYjCRؠFOԼ.{)Q=cr+ґ۸ؐ;3dl{elxpe,WyT)+UC9"7la:)  `&[鲙䑅u]J|[ /1s+ӮEE].sgbo.y)_?d-\|NK_Q<>aLLO۱P|Ӱv֣1fz8q=9XXo+fv ES+\OmvuyW<[KI]0ugE.YSRx {1W z~C ;014ۻo0 m웡4rфn!aD1 .9#Ꮦ$o)x>/u7hRA+9Xi4 ]ސo(||ytvٺ]x_rzvbzb}؟??]=χgAr{v%>]ݏg;C^~ czhx㟍6_pǰv<89v?6 wutKϝd^?=2-Zn O'5>~6,ypT [}\.#[cpñpCu؇1lr>Up0\o0K UeV~Ո>دd|6!ϯOOerC=K32aasCmi,5 E<^}\B5%B@RT,L9VH!,AL>" Js7Σ )XQRo%^J4df^Ac\sjUy*./~cS)4KȄ6 o5)8H&(We/sQj `/GBCpP򱜚5ORB _ȹPQYIjC2:>!,ohخx*,̀g{YEx ekiD{TCbh=_*ꭄ.C#x^f%jsd/ p6Q>#$X@_`gA8`\xSىH,Q/e\^`!ZezB҇QԘOJ3Si T[Y>RHEaY`aV3u/z]|N7dLJZJ 8LPK16SȡkBlsDx`fV`כK5-\+Y6U:ew;R?GUl *#нbV]dgUn gWU=x^!؞٧/ >Ӏ)¹XHJqO*%HԳBe`2dO1C3EiϖB$X89-{9-<FO+ Gۢq*)% Uی;508t+z(Ԉ ?QR Fe+qX\#H/0_em_$}`ŏo?pJ%UP3duW/1'!?ZVQn赵gM+1˙kRrzhN'!ĻvƦ=ku*>Ymo6_JJ{_ um損:; DۺȢKQɺ Izw}u$ 3PLn*]4?nW\嚽-J"boQ(zI48=e UZB*,`𸔏B"a-L,Elz5y%-d',`Wg׳ H3 o 6=.giq۸TJ(RRኯ[B4_ؕ/XׂXO mQ!̲DŽ-<:QC<`+ф# L^M\@H8.0xr[HB;sLK(iԔkp]+>rzz#!yV `rƺ5曍ȓU%a=v N!YT Sȵ`ky%?$u&S|+`=D{⊉`@D\Ek` ^!Vȅ70ZI"е bl2ڞgNR hmb0{~ fDA%5-NB cXb6bIFVf14:4Z{,-#$l21$O\Lue)|3aF}`+ iuW2['nONOJlВ Uh)%Xtp185N1ߒkY[!Edi%5_^6HѬx1}oaj5؂+'Uh.|[INSqvK(Kjy_ɝ/w{D#;ڠ5ʹp 2Kk*'1|9{7Jj-ߢ{biF1-uρ]EsKBTLף,1l-%cG ;o2 V?hd֛ 4RjV xS8@ D3m tB񈦰/~&xzҝpq{Gp 硯eGhX|b =]YdH3` ɓ* tfATal>W4<򴒙pуĎ3*!욼xN5:ED9 'm)Z1A 9jF -ܘZSgK H}8O y혆vFn }9kŶ!5Vh^ SA$'0W  7=pir!sm§hBI}p@~2P5W&GZ>ݾu[˨~\8ԘVTǽOOe%uyxyT*4-( <ݼp[TSҰqYnP,4FvW@ոrـ)>zۄ,mJGHHԚvܺbEG|;XS&Ԭoӵ8cU]XzYL EaG ~Bu-!fIڸ=~q;4N#@a=A s(^/]*|kM1{uib{/ /~Ɗ:2kUž@ʎK߫ܚt, FiWݰ%l:\XI2;@AF4SXs W`#iq dž7njJ%0Ya_dk"Z!xV$]@)nԅ_};)${URVD ==PlsZĖ ,$n+?LmR?zQIu;vTxyAx@5W*"(I7(Pe"pot> q0n^zmn&m86 hCTÒ]&Fmq`uֹo"iM` ~*~-8;dkR} Kd֔IEX}eSwc+&=C.EvCRT߅,ls\.BJ}N]JpBe KpxSe
$i18n{loading} ...
// 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(rltoscano): Move data/* into print_preview.data namespace // // 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('print_preview', function() { 'use strict'; /** * Class that represents a UI component. * @constructor * @extends {cr.EventTarget} */ function Component() { cr.EventTarget.call(this); /** * Component's HTML element. * @private {Element} */ this.element_ = null; this.isInDocument_ = false; /** * Component's event tracker. * @private {!EventTracker} */ this.tracker_ = new EventTracker(); /** * Component's WebUI listener tracker. * @private {!WebUIListenerTracker} */ this.listenerTracker_ = new WebUIListenerTracker(); /** * Child components of the component. * @private {!Array} */ this.children_ = []; } Component.prototype = { __proto__: cr.EventTarget.prototype, /** Gets the component's element. */ getElement: function() { return this.element_; }, /** @return {!EventTracker} Component's event tracker. */ get tracker() { return this.tracker_; }, /** @return {!WebUIListenerTracker} Component's Web UI listener tracker. */ get listenerTracker() { return this.listenerTracker_; }, /** * @return {boolean} Whether the element of the component is already in the * HTML document. */ get isInDocument() { return this.isInDocument_; }, /** * Creates the root element of the component. Sub-classes should override * this method. */ createDom: function() { this.element_ = cr.doc.createElement('div'); }, /** * Called when the component's element is known to be in the document. * Anything using document.getElementById etc. should be done at this stage. * Sub-classes should extend this method and attach listeners. */ enterDocument: function() { this.isInDocument_ = true; this.children_.forEach(function(child) { if (!child.isInDocument && child.getElement()) { child.enterDocument(); } }); }, /** Removes all event listeners. */ exitDocument: function() { this.children_.forEach(function(child) { if (child.isInDocument) { child.exitDocument(); } }); this.tracker_.removeAll(); this.listenerTracker_.removeAll(); this.isInDocument_ = false; }, /** * Renders this UI component and appends the element to the given parent * element. * @param {!Element} parentElement Element to render the component's * element into. */ render: function(parentElement) { assert(!this.isInDocument, 'Component is already in the document'); if (!this.element_) { this.createDom(); } parentElement.appendChild(this.element_); this.enterDocument(); }, /** * Decorates an existing DOM element. Sub-classes should override the * override the decorateInternal method. * @param {Element} element Element to decorate. */ decorate: function(element) { assert(!this.isInDocument, 'Component is already in the document'); this.setElementInternal(element); this.decorateInternal(); this.enterDocument(); }, /** * @return {!Array} Child components of this * component. */ get children() { return this.children_; }, /** * @param {!print_preview.Component} child Component to add as a child of * this component. */ addChild: function(child) { this.children_.push(child); }, /** * @param {!print_preview.Component} child Component to remove from this * component's children. */ removeChild: function(child) { const childIdx = this.children_.indexOf(child); if (childIdx != -1) { this.children_.splice(childIdx, 1); } if (child.isInDocument) { child.exitDocument(); if (child.getElement()) { child.getElement().parentNode.removeChild(child.getElement()); } } }, /** Removes all of the component's children. */ removeChildren: function() { while (this.children_.length > 0) { this.removeChild(this.children_[0]); } }, /** * @param {string} query Selector query to select an element starting from * the component's root element using a depth first search for the first * element that matches the query. * @return {!HTMLElement} Element selected by the given query. */ getChildElement: function(query) { return /** @type {!HTMLElement} */ ( assert(this.element_.querySelector(query))); }, /** * Sets the component's element. * @param {Element} element HTML element to set as the component's element. * @protected */ setElementInternal: function(element) { this.element_ = element; }, /** * Decorates the given element for use as the element of the component. * @protected */ decorateInternal: function() { /*abstract*/ }, /** * Clones a template HTML DOM tree. * @param {string} templateId Template element ID. * @param {boolean=} opt_keepHidden Whether to leave the cloned template * hidden after cloning. * @return {Element} Cloned element with its 'id' attribute stripped. * @protected */ cloneTemplateInternal: function(templateId, opt_keepHidden) { const templateEl = $(templateId); assert( templateEl != null, 'Could not find element with ID: ' + templateId); const el = assertInstanceof(templateEl.cloneNode(true), HTMLElement); el.id = ''; if (!opt_keepHidden) { setIsVisible(el, true); } return el; } }; return {Component: Component}; }); // // 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('print_preview', function() { 'use strict'; /** * FocusManager implementation specialized for Print Preview, which ensures * that Print Preview itself does not receive focus when an overlay is shown. * @constructor * @extends {cr.ui.FocusManager} */ function PrintPreviewFocusManager() {} cr.addSingletonGetter(PrintPreviewFocusManager); PrintPreviewFocusManager.prototype = { __proto__: cr.ui.FocusManager.prototype, /** @override */ getFocusParent: function() { let el = document.body; let newEl = null; while (newEl = el.querySelector('.overlay:not([hidden])')) el = newEl; return el; } }; // Export return {PrintPreviewFocusManager: PrintPreviewFocusManager}; }); // cr.exportPath('print_preview'); /** * States of the print preview. * @enum {string} * @private */ print_preview.PrintPreviewUiState_ = { INITIALIZING: 'initializing', READY: 'ready', OPENING_PDF_PREVIEW: 'opening-pdf-preview', OPENING_NATIVE_PRINT_DIALOG: 'opening-native-print-dialog', PRINTING: 'printing', FILE_SELECTION: 'file-selection', CLOSING: 'closing', ERROR: 'error' }; /** * What can happen when print preview tries to print. * @enum {string} * @private */ print_preview.PrintAttemptResult_ = { NOT_READY: 'not-ready', PRINTED: 'printed', READY_WAITING_FOR_PREVIEW: 'ready-waiting-for-preview' }; cr.define('print_preview', function() { 'use strict'; const PrintPreviewUiState_ = print_preview.PrintPreviewUiState_; /** * Container class for Chromium's print preview. * @constructor * @extends {print_preview.Component} */ function PrintPreview() { print_preview.Component.call(this); /** * Used to communicate with Chromium's print system. * @type {!print_preview.NativeLayer} * @private */ this.nativeLayer_ = print_preview.NativeLayer.getInstance(); /** * Event target that contains information about the logged in user. * @type {!print_preview.UserInfo} * @private */ this.userInfo_ = new print_preview.UserInfo(); /** * Data store which holds print destinations. * @type {!print_preview.DestinationStore} * @private */ this.destinationStore_ = new print_preview.DestinationStore( this.userInfo_, this.listenerTracker); /** * Application state. * @type {!print_preview.AppState} * @private */ this.appState_ = new print_preview.AppState(this.destinationStore_); /** * Data model that holds information about the document to print. * @type {!print_preview.DocumentInfo} * @private */ this.documentInfo_ = new print_preview.DocumentInfo(); /** * Data store which holds printer sharing invitations. * @type {!print_preview.InvitationStore} * @private */ this.invitationStore_ = new print_preview.InvitationStore(this.userInfo_); /** * Storage of the print ticket used to create the print job. * @type {!print_preview.PrintTicketStore} * @private */ this.printTicketStore_ = new print_preview.PrintTicketStore( this.destinationStore_, this.appState_, this.documentInfo_); /** * Holds the print and cancel buttons and renders some document statistics. * @type {!print_preview.PrintHeader} * @private */ this.printHeader_ = new print_preview.PrintHeader( this.printTicketStore_, this.destinationStore_); this.addChild(this.printHeader_); /** * Component used to search for print destinations. * @type {!print_preview.DestinationSearch} * @private */ this.destinationSearch_ = new print_preview.DestinationSearch( this.destinationStore_, this.invitationStore_, this.userInfo_, this.appState_); this.addChild(this.destinationSearch_); /** * Component that renders the print destination. * @type {!print_preview.DestinationSettings} * @private */ this.destinationSettings_ = new print_preview.DestinationSettings(this.destinationStore_); this.addChild(this.destinationSettings_); /** * Component that renders UI for entering in page range. * @type {!print_preview.PageSettings} * @private */ this.pageSettings_ = new print_preview.PageSettings(this.printTicketStore_.pageRange); this.addChild(this.pageSettings_); /** * Component that renders the copies settings. * @type {!print_preview.CopiesSettings} * @private */ this.copiesSettings_ = new print_preview.CopiesSettings( this.printTicketStore_.copies, this.printTicketStore_.collate); this.addChild(this.copiesSettings_); /** * Component that renders the layout settings. * @type {!print_preview.LayoutSettings} * @private */ this.layoutSettings_ = new print_preview.LayoutSettings(this.printTicketStore_.landscape); this.addChild(this.layoutSettings_); /** * Component that renders the color options. * @type {!print_preview.ColorSettings} * @private */ this.colorSettings_ = new print_preview.ColorSettings(this.printTicketStore_.color); this.addChild(this.colorSettings_); /** * Component that renders the media size settings. * @type {!print_preview.MediaSizeSettings} * @private */ this.mediaSizeSettings_ = new print_preview.MediaSizeSettings(this.printTicketStore_.mediaSize); this.addChild(this.mediaSizeSettings_); /** * Component that renders a select box for choosing margin settings. * @type {!print_preview.MarginSettings} * @private */ this.marginSettings_ = new print_preview.MarginSettings(this.printTicketStore_.marginsType); this.addChild(this.marginSettings_); /** * Component that renders the DPI settings. * @type {!print_preview.DpiSettings} * @private */ this.dpiSettings_ = new print_preview.DpiSettings(this.printTicketStore_.dpi); this.addChild(this.dpiSettings_); /** * Component that renders the scaling settings. * @type {!print_preview.ScalingSettings} * @private */ this.scalingSettings_ = new print_preview.ScalingSettings( this.printTicketStore_.scaling, this.printTicketStore_.fitToPage); this.addChild(this.scalingSettings_); /** * Component that renders miscellaneous print options. * @type {!print_preview.OtherOptionsSettings} * @private */ this.otherOptionsSettings_ = new print_preview.OtherOptionsSettings( this.printTicketStore_.duplex, this.printTicketStore_.cssBackground, this.printTicketStore_.selectionOnly, this.printTicketStore_.headerFooter, this.printTicketStore_.rasterize); this.addChild(this.otherOptionsSettings_); /** * Component that renders the advanced options button. * @type {!print_preview.AdvancedOptionsSettings} * @private */ this.advancedOptionsSettings_ = new print_preview.AdvancedOptionsSettings( this.printTicketStore_.vendorItems, this.destinationStore_); this.addChild(this.advancedOptionsSettings_); /** * Component used to search for print destinations. * @type {!print_preview.AdvancedSettings} * @private */ this.advancedSettings_ = new print_preview.AdvancedSettings(this.printTicketStore_); this.addChild(this.advancedSettings_); const settingsSections = [ this.destinationSettings_, this.pageSettings_, this.copiesSettings_, this.mediaSizeSettings_, this.layoutSettings_, this.marginSettings_, this.colorSettings_, this.dpiSettings_, this.scalingSettings_, this.otherOptionsSettings_, this.advancedOptionsSettings_ ]; /** * Component representing more/less settings button. * @type {!print_preview.MoreSettings} * @private */ this.moreSettings_ = new print_preview.MoreSettings( this.destinationStore_, settingsSections); this.addChild(this.moreSettings_); /** * Area of the UI that holds the print preview. * @type {!print_preview.PreviewArea} * @private */ this.previewArea_ = new print_preview.PreviewArea( this.destinationStore_, this.printTicketStore_, this.documentInfo_); this.addChild(this.previewArea_); /** * Interface to the Google Cloud Print API. Null if Google Cloud Print * integration is disabled. * @type {cloudprint.CloudPrintInterface} * @private */ this.cloudPrintInterface_ = null; /** * Whether in kiosk mode where print preview can print automatically without * user intervention. See http://crbug.com/31395. Print will start when * both the print ticket has been initialized, and an initial printer has * been selected. * @type {boolean} * @private */ this.isInKioskAutoPrintMode_ = false; /** * Whether Print Preview is in App Kiosk mode, basically, use only printers * available for the device. * @type {boolean} * @private */ this.isInAppKioskMode_ = false; /** * Whether Print with System Dialog link should be hidden. Overrides the * default rules for System dialog link visibility. * @type {boolean} * @private */ this.hideSystemDialogLink_ = true; /** * State of the print preview UI. * @type {print_preview.PrintPreviewUiState_} * @private */ this.uiState_ = PrintPreviewUiState_.INITIALIZING; /** * Whether document preview generation is in progress. * @type {boolean} * @private */ this.isPreviewGenerationInProgress_ = true; /** * Whether to show system dialog before next printing. * @type {boolean} * @private */ this.showSystemDialogBeforeNextPrint_ = false; /** * Whether the preview is listening for the manipulate-settings-for-test * UI event. * @private {boolean} */ this.isListeningForManipulateSettings_ = false; } PrintPreview.prototype = { __proto__: print_preview.Component.prototype, /** * @return {!print_preview.PreviewArea} The preview area. Used for tests. */ getPreviewArea: function() { return this.previewArea_; }, /** Sets up the page and print preview by getting the printer list. */ initialize: function() { this.decorate($('print-preview')); if (!this.previewArea_.hasCompatiblePlugin) { this.setIsEnabled_(false); } this.nativeLayer_.getInitialSettings().then( this.onInitialSettingsSet_.bind(this)); print_preview.PrintPreviewFocusManager.getInstance().initialize(); cr.ui.FocusOutlineManager.forDocument(document); this.listenerTracker.add('print-failed', this.onPrintFailed_.bind(this)); this.listenerTracker.add( 'use-cloud-print', this.onCloudPrintEnable_.bind(this)); this.listenerTracker.add( 'print-preset-options', this.onPrintPresetOptionsFromDocument_.bind(this)); this.listenerTracker.add( 'page-count-ready', this.onPageCountReady_.bind(this)); this.listenerTracker.add( 'enable-manipulate-settings-for-test', this.onEnableManipulateSettingsForTest_.bind(this)); }, /** @override */ enterDocument: function() { if ($('system-dialog-link')) { this.tracker.add( getRequiredElement('system-dialog-link'), 'click', this.openSystemPrintDialog_.bind(this)); } if ($('open-pdf-in-preview-link')) { this.tracker.add( getRequiredElement('open-pdf-in-preview-link'), 'click', this.onOpenPdfInPreviewLinkClick_.bind(this)); } this.tracker.add( this.previewArea_, print_preview.PreviewArea.EventType.PREVIEW_GENERATION_IN_PROGRESS, this.onPreviewGenerationInProgress_.bind(this)); this.tracker.add( this.previewArea_, print_preview.PreviewArea.EventType.PREVIEW_GENERATION_DONE, this.onPreviewGenerationDone_.bind(this)); this.tracker.add( this.previewArea_, print_preview.PreviewArea.EventType.PREVIEW_GENERATION_FAIL, this.onPreviewGenerationFail_.bind(this)); this.tracker.add( this.previewArea_, print_preview.PreviewArea.EventType.OPEN_SYSTEM_DIALOG_CLICK, this.openSystemPrintDialog_.bind(this)); this.tracker.add( this.previewArea_, print_preview.PreviewArea.EventType.SETTINGS_INVALID, this.onSettingsInvalid_.bind(this)); this.tracker.add( this.destinationStore_, print_preview.DestinationStore.EventType .SELECTED_DESTINATION_CAPABILITIES_READY, this.printIfReady_.bind(this)); this.tracker.add( this.destinationStore_, print_preview.DestinationStore.EventType.SELECTED_DESTINATION_INVALID, this.onSelectedDestinationInvalid_.bind(this)); this.tracker.add( this.destinationStore_, print_preview.DestinationStore.EventType.DESTINATION_SELECT, this.onDestinationSelect_.bind(this)); this.tracker.add( this.printHeader_, print_preview.PrintHeader.EventType.PRINT_BUTTON_CLICK, this.onPrintButtonClick_.bind(this)); this.tracker.add( this.printHeader_, print_preview.PrintHeader.EventType.CANCEL_BUTTON_CLICK, this.onCancelButtonClick_.bind(this)); this.tracker.add(window, 'keydown', this.onKeyDown_.bind(this)); this.previewArea_.setPluginKeyEventCallback(this.onKeyDown_.bind(this)); this.tracker.add( this.destinationSettings_, print_preview.DestinationSettings.EventType.CHANGE_BUTTON_ACTIVATE, this.onDestinationChangeButtonActivate_.bind(this)); this.tracker.add( this.destinationSearch_, print_preview.DestinationSearch.EventType.MANAGE_PRINT_DESTINATIONS, this.onManagePrintDestinationsActivated_.bind(this)); this.tracker.add( this.destinationSearch_, print_preview.DestinationSearch.EventType.ADD_ACCOUNT, this.onCloudPrintSignInActivated_.bind(this, true /*addAccount*/)); this.tracker.add( this.destinationSearch_, print_preview.DestinationSearch.EventType.SIGN_IN, this.onCloudPrintSignInActivated_.bind(this, false /*addAccount*/)); this.tracker.add( this.destinationSearch_, print_preview.DestinationListItem.EventType.REGISTER_PROMO_CLICKED, this.onCloudPrintRegisterPromoClick_.bind(this)); this.tracker.add( this.advancedOptionsSettings_, print_preview.AdvancedOptionsSettings.EventType.BUTTON_ACTIVATED, this.onAdvancedOptionsButtonActivated_.bind(this)); /* Ticket items that may be invalid. */ [this.printTicketStore_.copies, this.printTicketStore_.pageRange, this.printTicketStore_.scaling, ].forEach((item) => { this.tracker.add( item, print_preview.ticket_items.TicketItem.EventType.CHANGE, this.onTicketChange_.bind(this)); }); }, /** @override */ decorateInternal: function() { this.printHeader_.decorate($('print-header')); this.destinationSearch_.decorate($('destination-search')); this.destinationSettings_.decorate($('destination-settings')); this.pageSettings_.decorate($('page-settings')); this.copiesSettings_.decorate($('copies-settings')); this.layoutSettings_.decorate($('layout-settings')); this.colorSettings_.decorate($('color-settings')); this.mediaSizeSettings_.decorate($('media-size-settings')); this.marginSettings_.decorate($('margin-settings')); this.dpiSettings_.decorate($('dpi-settings')); this.scalingSettings_.decorate($('scaling-settings')); this.otherOptionsSettings_.decorate($('other-options-settings')); this.advancedOptionsSettings_.decorate($('advanced-options-settings')); this.advancedSettings_.decorate($('advanced-settings')); this.moreSettings_.decorate($('more-settings')); this.previewArea_.decorate($('preview-area')); }, /** * Sets whether the controls in the print preview are enabled. * @param {boolean} isEnabled Whether the controls in the print preview are * enabled. * @private */ setIsEnabled_: function(isEnabled) { if ($('system-dialog-link')) $('system-dialog-link').disabled = !isEnabled; if ($('open-pdf-in-preview-link')) $('open-pdf-in-preview-link').disabled = !isEnabled; this.printHeader_.isEnabled = isEnabled; this.destinationSettings_.isEnabled = isEnabled; this.pageSettings_.isEnabled = isEnabled; this.copiesSettings_.isEnabled = isEnabled; this.layoutSettings_.isEnabled = isEnabled; this.colorSettings_.isEnabled = isEnabled; this.mediaSizeSettings_.isEnabled = isEnabled; this.marginSettings_.isEnabled = isEnabled; this.dpiSettings_.isEnabled = isEnabled; this.scalingSettings_.isEnabled = isEnabled; this.otherOptionsSettings_.isEnabled = isEnabled; this.advancedOptionsSettings_.isEnabled = isEnabled; }, /** * Prints the document or launches a pdf preview on the local system. * @param {boolean} isPdfPreview Whether to launch the pdf preview. * @private */ printDocumentOrOpenPdfPreview_: function(isPdfPreview) { assert( this.uiState_ == PrintPreviewUiState_.READY, 'Print document request received when not in ready state: ' + this.uiState_); if (isPdfPreview) { this.uiState_ = PrintPreviewUiState_.OPENING_PDF_PREVIEW; } else if ( this.destinationStore_.selectedDestination.id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF) { this.uiState_ = PrintPreviewUiState_.FILE_SELECTION; } else { this.uiState_ = PrintPreviewUiState_.PRINTING; } this.setIsEnabled_(false); this.printHeader_.isCancelButtonEnabled = true; const printAttemptResult = this.printIfReady_(); if (printAttemptResult == print_preview.PrintAttemptResult_.READY_WAITING_FOR_PREVIEW) { if ((this.destinationStore_.selectedDestination.isLocal && !this.destinationStore_.selectedDestination.isPrivet && !this.destinationStore_.selectedDestination.isExtension && this.destinationStore_.selectedDestination.id != print_preview.Destination.GooglePromotedId.SAVE_AS_PDF) || this.uiState_ == PrintPreviewUiState_.OPENING_PDF_PREVIEW) { // Hide the dialog for now. The actual print command will be issued // when the preview generation is done. this.nativeLayer_.hidePreview(); } } }, /** * Attempts to print if needed and if ready. * @return {print_preview.PrintAttemptResult_} Attempt result. * @private */ printIfReady_: function() { const okToPrint = (this.uiState_ == PrintPreviewUiState_.PRINTING || this.uiState_ == PrintPreviewUiState_.OPENING_PDF_PREVIEW || this.uiState_ == PrintPreviewUiState_.FILE_SELECTION || this.isInKioskAutoPrintMode_) && this.destinationStore_.selectedDestination && this.destinationStore_.selectedDestination.capabilities; if (!okToPrint) { return print_preview.PrintAttemptResult_.NOT_READY; } if (this.isPreviewGenerationInProgress_) { return print_preview.PrintAttemptResult_.READY_WAITING_FOR_PREVIEW; } assert( this.printTicketStore_.isTicketValid(), 'Trying to print with invalid ticket'); if (getIsVisible(this.moreSettings_.getElement())) { new print_preview.PrintSettingsUiMetricsContext().record( this.moreSettings_.isExpanded ? print_preview.Metrics.PrintSettingsUiBucket .PRINT_WITH_SETTINGS_EXPANDED : print_preview.Metrics.PrintSettingsUiBucket .PRINT_WITH_SETTINGS_COLLAPSED); } const destination = assert(this.destinationStore_.selectedDestination); const whenPrintDone = this.sendPrintRequest_(destination); if (destination.isLocal) { const onError = destination.id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? this.onFileSelectionCancel_.bind(this) : this.onPrintFailed_.bind(this); whenPrintDone.then(this.close_.bind(this), onError); } else { // Cloud print resolves when print data is returned to submit to cloud // print, or if print ticket cannot be read, no PDF data is found, or // PDF is oversized. whenPrintDone.then( this.onPrintToCloud_.bind(this), this.onPrintFailed_.bind(this)); } this.showSystemDialogBeforeNextPrint_ = false; return print_preview.PrintAttemptResult_.PRINTED; }, /** * @param {!print_preview.Destination} destination Destination to print to. * @return {!Promise} Promise that resolves when print request is resolved * or rejected. * @private */ sendPrintRequest_: function(destination) { const printTicketStore = this.printTicketStore_; const documentInfo = this.documentInfo_; assert( printTicketStore.isTicketValid(), 'Trying to print when ticket is not valid'); assert( !this.showSystemDialogBeforeNextPrint_ || (cr.isWindows && destination.isLocal), 'Implemented for Windows only'); // Note: update // chrome/browser/ui/webui/print_preview/print_preview_handler_unittest.cc // with any changes to ticket creation. const ticket = { mediaSize: printTicketStore.mediaSize.getValue(), pageCount: printTicketStore.pageRange.getPageNumberSet().size, landscape: printTicketStore.landscape.getValue(), color: destination.getNativeColorModel(printTicketStore.color.getValue()), headerFooterEnabled: false, // Only used in print preview marginsType: printTicketStore.marginsType.getValue(), duplex: printTicketStore.duplex.getValue() ? print_preview.PreviewGenerator.DuplexMode.LONG_EDGE : print_preview.PreviewGenerator.DuplexMode.SIMPLEX, copies: printTicketStore.copies.getValueAsNumber(), collate: printTicketStore.collate.getValue(), shouldPrintBackgrounds: printTicketStore.cssBackground.getValue(), shouldPrintSelectionOnly: false, // Only used in print preview previewModifiable: documentInfo.isModifiable, printToPDF: destination.id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, printWithCloudPrint: !destination.isLocal, printWithPrivet: destination.isPrivet, printWithExtension: destination.isExtension, rasterizePDF: printTicketStore.rasterize.getValue(), scaleFactor: printTicketStore.scaling.getValueAsNumber(), dpiHorizontal: 'horizontal_dpi' in printTicketStore.dpi.getValue() ? printTicketStore.dpi.getValue().horizontal_dpi : 0, dpiVertical: 'vertical_dpi' in printTicketStore.dpi.getValue() ? printTicketStore.dpi.getValue().vertical_dpi : 0, deviceName: destination.id, fitToPageEnabled: printTicketStore.fitToPage.getValue(), pageWidth: documentInfo.pageSize.width, pageHeight: documentInfo.pageSize.height, showSystemDialog: this.showSystemDialogBeforeNextPrint_ }; if (!destination.isLocal) { // We can't set cloudPrintID if the destination is "Print with Cloud // Print" because the native system will try to print to Google Cloud // Print with this ID instead of opening a Google Cloud Print dialog. ticket.cloudPrintID = destination.id; } if (printTicketStore.marginsType.isCapabilityAvailable() && printTicketStore.marginsType.isValueEqual( print_preview.ticket_items.MarginsTypeValue.CUSTOM)) { const customMargins = printTicketStore.customMargins.getValue(); const orientationEnum = print_preview.ticket_items.CustomMarginsOrientation; ticket.marginsCustom = { marginTop: customMargins.get(orientationEnum.TOP), marginRight: customMargins.get(orientationEnum.RIGHT), marginBottom: customMargins.get(orientationEnum.BOTTOM), marginLeft: customMargins.get(orientationEnum.LEFT) }; } if (destination.isPrivet || destination.isExtension) { ticket.ticket = printTicketStore.createPrintTicket(destination); ticket.capabilities = JSON.stringify(destination.capabilities); } if (this.uiState_ == PrintPreviewUiState_.OPENING_PDF_PREVIEW) { ticket.OpenPDFInPreview = true; } return this.nativeLayer_.print(JSON.stringify(ticket)); }, /** * Closes the print preview. * @param {boolean} isCancel Whether this was called due to the user * closing the dialog without printing. * @private */ close_: function(isCancel) { this.exitDocument(); this.uiState_ = PrintPreviewUiState_.CLOSING; this.nativeLayer_.dialogClose(isCancel); }, /** * Opens the native system print dialog after disabling all controls. * @private */ openSystemPrintDialog_: function() { if (!this.shouldShowSystemDialogLink_()) return; if ($('system-dialog-link').classList.contains('disabled')) return; if (cr.isWindows) { this.showSystemDialogBeforeNextPrint_ = true; this.printDocumentOrOpenPdfPreview_(false /*isPdfPreview*/); return; } setIsVisible(getRequiredElement('system-dialog-throbber'), true); this.setIsEnabled_(false); this.uiState_ = PrintPreviewUiState_.OPENING_NATIVE_PRINT_DIALOG; this.nativeLayer_.showSystemDialog(); }, /** * Called when the native layer has initial settings to set. Sets the * initial settings of the print preview and begins fetching print * destinations. * @param {!print_preview.NativeInitialSettings} settings The initial print * preview settings persisted through the session. * @private */ onInitialSettingsSet_: function(settings) { assert( this.uiState_ == PrintPreviewUiState_.INITIALIZING, 'Updating initial settings when not in initializing state: ' + this.uiState_); this.uiState_ = PrintPreviewUiState_.READY; this.isInKioskAutoPrintMode_ = settings.isInKioskAutoPrintMode; this.isInAppKioskMode_ = settings.isInAppKioskMode; // The following components must be initialized in this order. this.appState_.init(settings.serializedAppStateStr); this.documentInfo_.init( settings.previewModifiable, settings.documentTitle, settings.documentHasSelection); this.printTicketStore_.init( settings.thousandsDelimeter, settings.decimalDelimeter, settings.unitType, settings.shouldPrintSelectionOnly); this.destinationStore_.init( settings.isInAppKioskMode, settings.printerName, settings.serializedDefaultDestinationSelectionRulesStr, this.appState_.recentDestinations || []); this.appState_.setInitialized(); // This is only visible in the task manager. $('document-title').innerText = settings.documentTitle; this.hideSystemDialogLink_ = settings.isInAppKioskMode; if ($('system-dialog-link')) { setIsVisible( getRequiredElement('system-dialog-link'), this.shouldShowSystemDialogLink_()); } }, /** * Called when Google Cloud Print integration is enabled by the * PrintPreviewHandler. * Fetches the user's cloud printers. * @param {string} cloudPrintUrl The URL to use for cloud print servers. * @param {boolean} appKioskMode Whether to print automatically for kiosk * mode. * @private */ onCloudPrintEnable_: function(cloudPrintUrl, appKioskMode) { this.cloudPrintInterface_ = new cloudprint.CloudPrintInterface( cloudPrintUrl, this.nativeLayer_, this.userInfo_, appKioskMode); this.tracker.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SUBMIT_DONE, this.onCloudPrintSubmitDone_.bind(this)); this.tracker.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SEARCH_FAILED, this.onCloudPrintError_.bind(this)); this.tracker.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SUBMIT_FAILED, this.onCloudPrintError_.bind(this)); this.tracker.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PRINTER_FAILED, this.onCloudPrintError_.bind(this)); this.destinationStore_.setCloudPrintInterface(this.cloudPrintInterface_); this.invitationStore_.setCloudPrintInterface(this.cloudPrintInterface_); if (this.destinationSearch_.getIsVisible()) { this.destinationStore_.startLoadCloudDestinations(); this.invitationStore_.startLoadingInvitations(); } }, /** * Called from the native layer when ready to print to Google Cloud Print. * @param {string} data The body to send in the HTTP request. * @private */ onPrintToCloud_: function(data) { assert( this.uiState_ == PrintPreviewUiState_.PRINTING, 'Document ready to be sent to the cloud when not in printing ' + 'state: ' + this.uiState_); assert( this.cloudPrintInterface_ != null, 'Google Cloud Print is not enabled'); const destination = this.destinationStore_.selectedDestination; assert(destination != null); this.cloudPrintInterface_.submit( destination, this.printTicketStore_.createPrintTicket(destination), this.documentInfo_, data); }, /** * Called from the native layer when the user cancels the save-to-pdf file * selection dialog. * @private */ onFileSelectionCancel_: function() { assert( this.uiState_ == PrintPreviewUiState_.FILE_SELECTION, 'File selection cancelled when not in file-selection state: ' + this.uiState_); this.setIsEnabled_(true); this.uiState_ = PrintPreviewUiState_.READY; }, /** * Called after successfully submitting a job to Google Cloud Print. * @param {!Event} event Contains the ID of the submitted print job. * @private */ onCloudPrintSubmitDone_: function(event) { assert( this.uiState_ == PrintPreviewUiState_.PRINTING, 'Submited job to Google Cloud Print but not in printing state ' + this.uiState_); this.close_(false); }, /** * Called when there was an error communicating with Google Cloud print. * Displays an error message in the print header. * @param {!Event} event Contains the error message. * @private */ onCloudPrintError_: function(event) { if (event.status == 0) { return; // Ignore, the system does not have internet connectivity. } if (event.status == 403) { if (!this.isInAppKioskMode_) { this.destinationSearch_.showCloudPrintPromo(); } } else { this.printHeader_.setErrorMessage(event.message); } if (event.status == 200) { console.error( 'Google Cloud Print Error: (' + event.errorCode + ') ' + event.message); } else { console.error('Google Cloud Print Error: HTTP status ' + event.status); } }, /** * Called when the preview area's preview generation is in progress. * @private */ onPreviewGenerationInProgress_: function() { this.isPreviewGenerationInProgress_ = true; }, /** * Called when the preview area's preview generation is complete. * @private */ onPreviewGenerationDone_: function() { this.isPreviewGenerationInProgress_ = false; this.printHeader_.isPrintButtonEnabled = true; if (this.isListeningForManipulateSettings_) this.nativeLayer_.uiLoadedForTest(); this.printIfReady_(); }, /** * Called when the preview area's preview failed to load. * @private */ onPreviewGenerationFail_: function() { this.isPreviewGenerationInProgress_ = false; this.printHeader_.isPrintButtonEnabled = false; if (this.uiState_ == PrintPreviewUiState_.PRINTING) this.nativeLayer_.cancelPendingPrintRequest(); }, /** * Called when the 'Open pdf in preview' link is clicked. Launches the pdf * preview app. * @private */ onOpenPdfInPreviewLinkClick_: function() { if ($('open-pdf-in-preview-link').classList.contains('disabled')) return; assert( this.uiState_ == PrintPreviewUiState_.READY, 'Trying to open pdf in preview when not in ready state: ' + this.uiState_); setIsVisible(getRequiredElement('open-preview-app-throbber'), true); this.previewArea_.showCustomMessage( loadTimeData.getString('openingPDFInPreview')); this.printDocumentOrOpenPdfPreview_(true /*isPdfPreview*/); }, /** * Called when the print header's print button is clicked. Prints the * document. * @private */ onPrintButtonClick_: function() { assert( this.uiState_ == PrintPreviewUiState_.READY, 'Trying to print when not in ready state: ' + this.uiState_); this.printDocumentOrOpenPdfPreview_(false /*isPdfPreview*/); }, /** * Called when the print header's cancel button is clicked. Closes the * print dialog. * @private */ onCancelButtonClick_: function() { this.close_(true); }, /** * Called when the register promo for Cloud Print is clicked. * @private */ onCloudPrintRegisterPromoClick_: function(e) { const devicesUrl = 'chrome://devices/register?id=' + e.destination.id; this.nativeLayer_.forceOpenNewTab(devicesUrl); this.destinationStore_.waitForRegister(e.destination.id); }, /** * Consume escape key presses and ctrl + shift + p. Delegate everything else * to the preview area. * @param {KeyboardEvent} e The keyboard event. * @private * @suppress {uselessCode} * Current compiler preprocessor leaves all the code inside all the s, * so the compiler claims that code after first return is unreachable. */ onKeyDown_: function(e) { // Escape key closes the dialog. if (e.keyCode == 27 && !hasKeyModifiers(e)) { // On non-mac with toolkit-views, ESC key is handled by C++-side instead // of JS-side. if (cr.isMac) { this.close_(true); e.preventDefault(); } return; } // On Mac, Cmd-. should close the print dialog. if (cr.isMac && e.keyCode == 190 && e.metaKey) { this.close_(true); e.preventDefault(); return; } // Ctrl + Shift + p / Mac equivalent. if (e.keyCode == 80) { if ((cr.isMac && e.metaKey && e.altKey && !e.shiftKey && !e.ctrlKey) || (!cr.isMac && e.shiftKey && e.ctrlKey && !e.altKey && !e.metaKey)) { this.openSystemPrintDialog_(); e.preventDefault(); return; } } if (e.keyCode == 13 /*enter*/ && !document.querySelector('.overlay:not([hidden])') && this.destinationStore_.selectedDestination && this.printTicketStore_.isTicketValid() && this.printHeader_.isPrintButtonEnabled) { assert( this.uiState_ == PrintPreviewUiState_.READY, 'Trying to print when not in ready state: ' + this.uiState_); const activeElementTag = document.activeElement.tagName.toUpperCase(); if (activeElementTag != 'BUTTON' && activeElementTag != 'SELECT' && activeElementTag != 'A') { this.printDocumentOrOpenPdfPreview_(false /*isPdfPreview*/); e.preventDefault(); } return; } // Pass certain directional keyboard events to the PDF viewer. this.previewArea_.handleDirectionalKeyEvent(e); }, /** * Called when the destination store fails to fetch capabilities for the * selected printer. * @private */ onSelectedDestinationInvalid_: function() { this.previewArea_.showCustomMessage( loadTimeData.getString('invalidPrinterSettings')); this.onSettingsInvalid_(); }, /** * Called when native layer receives invalid settings for a print request. * @private */ onSettingsInvalid_: function() { this.uiState_ = PrintPreviewUiState_.ERROR; this.isPreviewGenerationInProgress_ = false; this.printHeader_.isPrintButtonEnabled = false; }, /** * Called when a ticket item that can be invalid is updated. Updates the * enabled state of the system dialog link on Windows and the open pdf in * preview link on Mac. * @private */ onTicketChange_: function() { this.printHeader_.onTicketChange(); const disable = !this.printHeader_.isPrintButtonEnabled; if (cr.isWindows && $('system-dialog-link')) $('system-dialog-link').disabled = disable; if ($('open-pdf-in-preview-link')) $('open-pdf-in-preview-link').disabled = disable; }, /** * Called when the destination settings' change button is activated. * Displays the destination search component. * @private */ onDestinationChangeButtonActivate_: function() { this.destinationSearch_.setIsVisible(true); }, /** * Called when the destination settings' change button is activated. * Displays the destination search component. * @private */ onAdvancedOptionsButtonActivated_: function() { this.advancedSettings_.showForDestination( assert(this.destinationStore_.selectedDestination)); }, /** * Called when the destination search dispatches manage all print * destinations event. Calls corresponding native layer method. * @private */ onManagePrintDestinationsActivated_: function() { this.nativeLayer_.managePrinters(); }, /** * Called when the user wants to sign in to Google Cloud Print. Calls the * corresponding native layer event. * @param {boolean} addAccount Whether to open an 'add a new account' or * default sign in page. * @private */ onCloudPrintSignInActivated_: function(addAccount) { this.nativeLayer_.signIn(addAccount) .then(this.destinationStore_.onDestinationsReload.bind( this.destinationStore_)); }, /** * Updates printing options according to source document presets. * @param {boolean} disableScaling Whether the document disables scaling. * @param {number} copies The default number of copies from the document. * @param {number} duplex The default duplex setting from the document. * @private */ onPrintPresetOptionsFromDocument_: function( disableScaling, copies, duplex) { if (disableScaling) this.documentInfo_.updateIsScalingDisabled(true); if (copies > 0 && this.printTicketStore_.copies.isCapabilityAvailable()) { this.printTicketStore_.copies.updateValue(copies); } if (duplex >= 0 & this.printTicketStore_.duplex.isCapabilityAvailable()) { this.printTicketStore_.duplex.updateValue(duplex); } }, /** * Called when the Page Count Ready message is received to update the fit to * page scaling value in the scaling settings. * @param {number} pageCount The document's page count (unused). * @param {number} previewResponseId The request ID that corresponds to this * page count (unused). * @param {number} fitToPageScaling The scaling required to fit the document * to page. * @private */ onPageCountReady_: function( pageCount, previewResponseId, fitToPageScaling) { if (fitToPageScaling >= 0) { this.scalingSettings_.updateFitToPageScaling(fitToPageScaling); } }, /** * Called when printing to a privet, cloud, or extension printer fails. * @param {*} httpError The HTTP error code, or -1 or a string describing * the error, if not an HTTP error. * @private */ onPrintFailed_: function(httpError) { console.error('Printing failed with error code ' + httpError); this.printHeader_.setErrorMessage( loadTimeData.getString('couldNotPrint')); }, /** * Called to start listening for the manipulate-settings-for-test WebUI * event so that settings can be modified by this event. * @private */ onEnableManipulateSettingsForTest_: function() { this.listenerTracker.add( 'manipulate-settings-for-test', this.onManipulateSettingsForTest_.bind(this)); this.isListeningForManipulateSettings_ = true; }, /** * Called when the print preview settings need to be changed for testing. * @param {!print_preview.PreviewSettings} settings Contains print preview * settings to change and the values to change them to. * @private */ onManipulateSettingsForTest_: function(settings) { if ('selectSaveAsPdfDestination' in settings) { this.saveAsPdfForTest_(); // No parameters. } else if ('layoutSettings' in settings) { this.setLayoutSettingsForTest_(settings.layoutSettings.portrait); } else if ('pageRange' in settings) { this.setPageRangeForTest_(settings.pageRange); } else if ('headersAndFooters' in settings) { this.setHeadersAndFootersForTest_(settings.headersAndFooters); } else if ('backgroundColorsAndImages' in settings) { this.setBackgroundColorsAndImagesForTest_( settings.backgroundColorsAndImages); } else if ('margins' in settings) { this.setMarginsForTest_(settings.margins); } }, /** * Called by onManipulateSettingsForTest_(). Sets the print destination * as a pdf. * @private */ saveAsPdfForTest_: function() { if (this.destinationStore_.selectedDestination && print_preview.Destination.GooglePromotedId.SAVE_AS_PDF == this.destinationStore_.selectedDestination.id) { this.nativeLayer_.uiLoadedForTest(); return; } const destinations = this.destinationStore_.destinations(); let pdfDestination = null; for (let i = 0; i < destinations.length; i++) { if (destinations[i].id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF) { pdfDestination = destinations[i]; break; } } if (pdfDestination) this.destinationStore_.selectDestination(pdfDestination); else this.nativeLayer_.uiFailedLoadingForTest(); }, /** * Called by onManipulateSettingsForTest_(). Sets the layout settings to * either portrait or landscape. * @param {boolean} portrait Whether to use portrait page layout; * if false: landscape. * @private */ setLayoutSettingsForTest_: function(portrait) { const combobox = document.querySelector('.layout-settings-select'); if (combobox.value == 'portrait') { this.nativeLayer_.uiLoadedForTest(); } else { combobox.value = 'landscape'; this.layoutSettings_.onSelectChange_(); } }, /** * Called by onManipulateSettingsForTest_(). Sets the page range for * for the print preview settings. * @param {string} pageRange Sets the page range to the desired value(s). * Ex: "1-5,9" means pages 1 through 5 and page 9 will be printed. * @private */ setPageRangeForTest_: function(pageRange) { const textbox = document.querySelector('.page-settings-custom-input'); if (textbox.value == pageRange) { this.nativeLayer_.uiLoadedForTest(); } else { textbox.value = pageRange; document.querySelector('.page-settings-custom-radio').click(); } }, /** * Called by onManipulateSettings_(). Checks or unchecks the headers and * footers option on print preview. * @param {boolean} headersAndFooters Whether the "Headers and Footers" * checkbox should be checked. * @private */ setHeadersAndFootersForTest_: function(headersAndFooters) { const checkbox = document.querySelector('.header-footer-checkbox'); if (headersAndFooters == checkbox.checked) this.nativeLayer_.uiLoadedForTest(); else checkbox.click(); }, /** * Called by onManipulateSettings_(). Checks or unchecks the background * colors and images option on print preview. * @param {boolean} backgroundColorsAndImages If true, the checkbox should * be checked. Otherwise it should be unchecked. * @private */ setBackgroundColorsAndImagesForTest_: function(backgroundColorsAndImages) { const checkbox = document.querySelector('.css-background-checkbox'); if (backgroundColorsAndImages == checkbox.checked) this.nativeLayer_.uiLoadedForTest(); else checkbox.click(); }, /** * Called by onManipulateSettings_(). Sets the margin settings * that are desired. Custom margin settings aren't currently supported. * @param {number} margins The desired margins combobox index. Must be * a valid index or else the test fails. * @private */ setMarginsForTest_: function(margins) { const combobox = document.querySelector('.margin-settings-select'); if (margins == combobox.selectedIndex) { this.nativeLayer_.uiLoadedForTest(); } else if (margins >= 0 && margins < combobox.length) { combobox.selectedIndex = margins; this.marginSettings_.onSelectChange_(); } else { this.nativeLayer_.uiFailedLoadingForTest(); } }, /** * Returns true if "Print using system dialog" link should be shown for * current destination. * @return {boolean} Returns true if link should be shown. */ shouldShowSystemDialogLink_: function() { if (cr.isChromeOS || this.hideSystemDialogLink_) return false; if (!cr.isWindows) return true; const selectedDest = this.destinationStore_.selectedDestination; return !!selectedDest && selectedDest.origin == print_preview.DestinationOrigin.LOCAL && selectedDest.id != print_preview.Destination.GooglePromotedId.SAVE_AS_PDF; }, /** * Called when a print destination is selected. Shows/hides the "Print with * Cloud Print" link in the navbar. * @private */ onDestinationSelect_: function() { if ($('system-dialog-link')) { setIsVisible( getRequiredElement('system-dialog-link'), this.shouldShowSystemDialogLink_()); } // Reset if we had a bad settings fetch since the user selected a new // printer. if (this.uiState_ == PrintPreviewUiState_.ERROR) this.uiState_ = PrintPreviewUiState_.READY; if (this.destinationStore_.selectedDestination && this.isInKioskAutoPrintMode_) { this.onPrintButtonClick_(); } }, }; // Export return {PrintPreview: PrintPreview}; }); // Pull in all other scripts in a single shot. // // 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('print_preview', function() { 'use strict'; /** * Modal dialog base component. * @constructor * @extends {print_preview.Component} */ function Overlay() { print_preview.Component.call(this); } Overlay.prototype = { __proto__: print_preview.Component.prototype, /** @override */ enterDocument: function() { print_preview.Component.prototype.enterDocument.call(this); this.getElement().addEventListener('transitionend', function f(e) { if (e.target == e.currentTarget && e.propertyName == 'opacity' && e.target.classList.contains('transparent')) { setIsVisible(e.target, false); } }); this.getElement().addEventListener('keydown', function f(e) { // Escape pressed -> cancel the dialog. if (!hasKeyModifiers(e)) { if (e.keyCode == 27) { e.stopPropagation(); e.preventDefault(); this.cancel(); } else if (e.keyCode == 13) { const activeElementTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : ''; if (activeElementTag != 'BUTTON' && activeElementTag != 'SELECT') { if (this.onEnterPressedInternal()) { e.stopPropagation(); e.preventDefault(); } } } } }.bind(this)); this.tracker.add( this.getChildElement('.page > .close-button'), 'click', this.cancel.bind(this)); this.tracker.add( this.getElement(), 'click', this.onOverlayClick_.bind(this)); this.tracker.add( this.getChildElement('.page'), 'animationend', this.onAnimationEnd_.bind(this)); }, /** @return {boolean} Whether the component is visible. */ getIsVisible: function() { return !this.getElement().classList.contains('transparent'); }, /** @param {boolean} isVisible Whether the component is visible. */ setIsVisible: function(isVisible) { if (this.getIsVisible() == isVisible) return; if (isVisible) { setIsVisible(this.getElement(), true); setTimeout(() => { this.getElement().classList.remove('transparent'); }, 0); } else { this.getElement().classList.add('transparent'); } this.onSetVisibleInternal(isVisible); }, /** Closes the dialog. */ cancel: function() { this.setIsVisible(false); this.onCancelInternal(); }, /** * @param {boolean} isVisible Whether the component is visible. * @protected */ onSetVisibleInternal: function(isVisible) {}, /** @protected */ onCancelInternal: function() {}, /** * @return {boolean} Whether the event was handled. * @protected */ onEnterPressedInternal: function() { return false; }, /** * Called when the overlay is clicked. Pulses the page. * @param {Event} e Contains the element that was clicked. * @private */ onOverlayClick_: function(e) { if (e.target && e.target.classList.contains('overlay')) e.target.querySelector('.page').classList.add('pulse'); }, /** * Called when an animation ends on the page. * @param {Event} e Contains the target done animating. * @private */ onAnimationEnd_: function(e) { if (e.target && e.animationName == 'pulse') e.target.classList.remove('pulse'); } }; // Export return {Overlay: Overlay}; }); // // 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('print_preview', function() { 'use strict'; /** * Component that renders a search box for searching through destinations. * @param {string} searchBoxPlaceholderText Search box placeholder text. * @constructor * @extends {print_preview.Component} */ function SearchBox(searchBoxPlaceholderText) { print_preview.Component.call(this); /** * Search box placeholder text. * @private {string} */ this.searchBoxPlaceholderText_ = searchBoxPlaceholderText; /** * Timeout used to control incremental search. * @private {?number} */ this.timeout_ = null; /** * Input box where the query is entered. * @private {HTMLInputElement} */ this.input_ = null; } /** * Enumeration of event types dispatched from the search box. * @enum {string} */ SearchBox.EventType = {SEARCH: 'print_preview.SearchBox.SEARCH'}; /** * Delay in milliseconds before dispatching a SEARCH event. * @private {number} * @const */ SearchBox.SEARCH_DELAY_ = 150; SearchBox.prototype = { __proto__: print_preview.Component.prototype, /** @param {?string} query New query to set the search box's query to. */ setQuery: function(query) { query = query || ''; this.input_.value = query.trim(); }, /** Sets the input element of the search box in focus. */ focus: function() { this.input_.focus(); }, /** @override */ createDom: function() { this.setElementInternal( this.cloneTemplateInternal('search-box-template')); this.input_ = assertInstanceof( this.getChildElement('.search-box-input'), HTMLInputElement); this.input_.setAttribute('placeholder', this.searchBoxPlaceholderText_); }, /** @override */ enterDocument: function() { print_preview.Component.prototype.enterDocument.call(this); this.tracker.add( assert(this.input_), 'input', this.onInputInput_.bind(this)); }, /** @override */ exitDocument: function() { print_preview.Component.prototype.exitDocument.call(this); this.input_ = null; }, /** * @return {string} The current query of the search box. * @private */ getQuery_: function() { return this.input_.value.trim(); }, /** * Dispatches a SEARCH event. * @private */ dispatchSearchEvent_: function() { this.timeout_ = null; const searchEvent = new Event(SearchBox.EventType.SEARCH); const query = this.getQuery_(); searchEvent.query = query; if (query) { // Generate regexp-safe query by escaping metacharacters. const safeQuery = query.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); searchEvent.queryRegExp = new RegExp('(' + safeQuery + ')', 'ig'); } else { searchEvent.queryRegExp = null; } this.dispatchEvent(searchEvent); }, /** * Called when the input element's value changes. Dispatches a search event. * @private */ onInputInput_: function() { if (this.timeout_) clearTimeout(this.timeout_); this.timeout_ = setTimeout( this.dispatchSearchEvent_.bind(this), SearchBox.SEARCH_DELAY_); } }; // Export return {SearchBox: SearchBox}; }); // // 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('print_preview', function() { 'use strict'; /** * Encapsulated handling of a search bubble. * @constructor * @extends {HTMLDivElement} */ function SearchBubble(text) { const el = cr.doc.createElement('div'); SearchBubble.decorate(el); el.content = text; return el; } SearchBubble.decorate = function(el) { el.__proto__ = SearchBubble.prototype; el.decorate(); }; SearchBubble.prototype = { __proto__: HTMLDivElement.prototype, decorate: function() { this.className = 'search-bubble'; this.innards_ = cr.doc.createElement('div'); this.innards_.className = 'search-bubble-innards'; this.appendChild(this.innards_); // We create a timer to periodically update the position of the bubbles. // While this isn't all that desirable, it's the only sure-fire way of // making sure the bubbles stay in the correct location as sections // may dynamically change size at any time. this.intervalId = setInterval(this.updatePosition.bind(this), 250); }, /** * Sets the text message in the bubble. * @param {string} text The text the bubble will show. */ set content(text) { this.innards_.textContent = text; }, /** Attach the bubble to the element. */ attachTo: function(element) { const parent = element.parentElement; if (!parent) return; if (parent.tagName == 'TD') { // To make absolute positioning work inside a table cell we need // to wrap the bubble div into another div with position:relative. // This only works properly if the element is the first child of the // table cell which is true for all options pages (the only place // it is used on tables). this.wrapper = cr.doc.createElement('div'); this.wrapper.className = 'search-bubble-wrapper'; this.wrapper.appendChild(this); parent.insertBefore(this.wrapper, element); } else { parent.insertBefore(this, element); } this.updatePosition(); }, /** Clear the interval timer and remove the element from the page. */ dispose: function() { clearInterval(this.intervalId); const child = this.wrapper || this; const parent = child.parentNode; if (parent) parent.removeChild(child); }, /** * Update the position of the bubble. Called at creation time and then * periodically while the bubble remains visible. */ updatePosition: function() { // This bubble is 'owned' by the next sibling. const owner = (this.wrapper || this).nextSibling; // If there isn't an offset parent, we have nothing to do. if (!owner.offsetParent) return; // Position the bubble below the location of the owner. const left = owner.offsetLeft + owner.offsetWidth / 2 - this.offsetWidth / 2; const top = owner.offsetTop + owner.offsetHeight; // Update the position in the CSS. Cache the last values for // best performance. if (left != this.lastLeft) { this.style.left = left + 'px'; this.lastLeft = left; } if (top != this.lastTop) { this.style.top = top + 'px'; this.lastTop = top; } }, }; // Export return {SearchBubble: SearchBubble}; }); // // 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('print_preview', function() { 'use strict'; class PageNumberSet { /** * An immutable ordered set of page numbers. * @param {!Array} pageNumberList A list of page numbers to include * in the set. */ constructor(pageNumberList) { /** * Internal data store for the page number set. * @type {!Array} * @private */ this.pageNumberSet_ = pageListToPageSet(pageNumberList); } /** @return {number} The number of page numbers in the set. */ get size() { return this.pageNumberSet_.length; } /** * @param {number} index 0-based index of the page number to get. * @return {number} Page number at the given index. */ getPageNumberAt(index) { return this.pageNumberSet_[index]; } /** * @param {number} pageNumber 1-based page number to check for. * @return {boolean} Whether the given page number is in the page range. */ hasPageNumber(pageNumber) { return arrayContains(this.pageNumberSet_, pageNumber); } /** * @param {number} pageNumber 1-based number of the page to get index of. * @return {number} 0-based index of the given page number with respect to * all of the pages in the page range. */ getPageNumberIndex(pageNumber) { return this.pageNumberSet_.indexOf(pageNumber); } /** @return {!Array} Array representation of the set. */ asArray() { return this.pageNumberSet_.slice(0); } } // Export return {PageNumberSet: PageNumberSet}; }); // // 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.exportPath('print_preview'); /** * Enumeration of the types of destinations. * @enum {string} */ print_preview.DestinationType = { GOOGLE: 'google', GOOGLE_PROMOTED: 'google_promoted', LOCAL: 'local', MOBILE: 'mobile' }; /** * Enumeration of the origin types for cloud destinations. * @enum {string} */ print_preview.DestinationOrigin = { LOCAL: 'local', COOKIES: 'cookies', DEVICE: 'device', PRIVET: 'privet', EXTENSION: 'extension', CROS: 'chrome_os', }; /** * Enumeration of the connection statuses of printer destinations. * @enum {string} */ print_preview.DestinationConnectionStatus = { DORMANT: 'DORMANT', OFFLINE: 'OFFLINE', ONLINE: 'ONLINE', UNKNOWN: 'UNKNOWN', UNREGISTERED: 'UNREGISTERED' }; /** * Enumeration specifying whether a destination is provisional and the reason * the destination is provisional. * @enum {string} */ print_preview.DestinationProvisionalType = { /** Destination is not provisional. */ NONE: 'NONE', /** * User has to grant USB access for the destination to its provider. * Used for destinations with extension origin. */ NEEDS_USB_PERMISSION: 'NEEDS_USB_PERMISSION' }; /** * Capabilities of a print destination represented in a CDD. * * @typedef {{ * vendor_capability: !Array<{Object}>, * collate: ({default: (boolean|undefined)}|undefined), * color: ({ * option: !Array<{ * type: (string|undefined), * vendor_id: (string|undefined), * custom_display_name: (string|undefined), * is_default: (boolean|undefined) * }> * }|undefined), * copies: ({default: (number|undefined), * max: (number|undefined)}|undefined), * duplex: ({option: !Array<{type: (string|undefined), * is_default: (boolean|undefined)}>}|undefined), * page_orientation: ({ * option: !Array<{type: (string|undefined), * is_default: (boolean|undefined)}> * }|undefined), * media_size: ({ * option: !Array<{ * type: (string|undefined), * vendor_id: (string|undefined), * custom_display_name: (string|undefined), * is_default: (boolean|undefined) * }> * }|undefined), * dpi: ({ * option: !Array<{ * vendor_id: (string|undefined), * height_microns: number, * width_microns: number, * is_default: (boolean|undefined) * }> * }|undefined) * }} */ print_preview.CddCapabilities; /** * The CDD (Cloud Device Description) describes the capabilities of a print * destination. * * @typedef {{ * version: string, * printer: !print_preview.CddCapabilities, * }} */ print_preview.Cdd; /** * Enumeration of color modes used by Chromium. * @enum {number} */ print_preview.ColorMode = { GRAY: 1, COLOR: 2 }; /** * @typedef {{id: string, * origin: print_preview.DestinationOrigin, * account: string, * capabilities: ?print_preview.Cdd, * displayName: string, * extensionId: string, * extensionName: string}} */ print_preview.RecentDestination; cr.define('print_preview', function() { 'use strict'; /** * Creates a |RecentDestination| to represent |destination| in the app * state. * @param {!print_preview.Destination} destination The destination to store. * @return {!print_preview.RecentDestination} */ function makeRecentDestination(destination) { return { id: destination.id, origin: destination.origin, account: destination.account || '', capabilities: destination.capabilities, displayName: destination.displayName || '', extensionId: destination.extensionId || '', extensionName: destination.extensionName || '', }; } class Destination { /** * Print destination data object that holds data for both local and cloud * destinations. * @param {string} id ID of the destination. * @param {!print_preview.DestinationType} type Type of the destination. * @param {!print_preview.DestinationOrigin} origin Origin of the * destination. * @param {string} displayName Display name of the destination. * @param {boolean} isRecent Whether the destination has been used recently. * @param {!print_preview.DestinationConnectionStatus} connectionStatus * Connection status of the print destination. * @param {{tags: (Array|undefined), * isOwned: (boolean|undefined), * isEnterprisePrinter: (boolean|undefined), * account: (string|undefined), * lastAccessTime: (number|undefined), * cloudID: (string|undefined), * provisionalType: * (print_preview.DestinationProvisionalType|undefined), * extensionId: (string|undefined), * extensionName: (string|undefined), * description: (string|undefined)}=} opt_params Optional * parameters for the destination. */ constructor( id, type, origin, displayName, isRecent, connectionStatus, opt_params) { /** * ID of the destination. * @private {string} */ this.id_ = id; /** * Type of the destination. * @private {!print_preview.DestinationType} */ this.type_ = type; /** * Origin of the destination. * @private {!print_preview.DestinationOrigin} */ this.origin_ = origin; /** * Display name of the destination. * @private {string} */ this.displayName_ = displayName || ''; /** * Whether the destination has been used recently. * @private {boolean} */ this.isRecent_ = isRecent; /** * Tags associated with the destination. * @private {!Array} */ this.tags_ = (opt_params && opt_params.tags) || []; /** * Print capabilities of the destination. * @private {?print_preview.Cdd} */ this.capabilities_ = null; /** * Whether the destination is owned by the user. * @private {boolean} */ this.isOwned_ = (opt_params && opt_params.isOwned) || false; /** * Whether the destination is an enterprise policy controlled printer. * @private {boolean} */ this.isEnterprisePrinter_ = (opt_params && opt_params.isEnterprisePrinter) || false; /** * Account this destination is registered for, if known. * @private {string} */ this.account_ = (opt_params && opt_params.account) || ''; /** * Cache of destination location fetched from tags. * @private {?string} */ this.location_ = null; /** * Printer description. * @private {string} */ this.description_ = (opt_params && opt_params.description) || ''; /** * Connection status of the destination. * @private {!print_preview.DestinationConnectionStatus} */ this.connectionStatus_ = connectionStatus; /** * Number of milliseconds since the epoch when the printer was last * accessed. * @private {number} */ this.lastAccessTime_ = (opt_params && opt_params.lastAccessTime) || Date.now(); /** * Cloud ID for Privet printers. * @private {string} */ this.cloudID_ = (opt_params && opt_params.cloudID) || ''; /** * Extension ID for extension managed printers. * @private {string} */ this.extensionId_ = (opt_params && opt_params.extensionId) || ''; /** * Extension name for extension managed printers. * @private {string} */ this.extensionName_ = (opt_params && opt_params.extensionName) || ''; /** * Different from {@code print_preview.DestinationProvisionalType.NONE} if * the destination is provisional. Provisional destinations cannot be * selected as they are, but have to be resolved first (i.e. extra steps * have to be taken to get actual destination properties, which should * replace the provisional ones). Provisional destination resolvment flow * will be started when the user attempts to select the destination in * search UI. * @private {print_preview.DestinationProvisionalType} */ this.provisionalType_ = (opt_params && opt_params.provisionalType) || print_preview.DestinationProvisionalType.NONE; assert( this.provisionalType_ != print_preview.DestinationProvisionalType .NEEDS_USB_PERMISSION || this.isExtension, 'Provisional USB destination only supprted with extension origin.'); /** * @private {!Array} List of capability types considered color. * @const */ this.COLOR_TYPES_ = ['STANDARD_COLOR', 'CUSTOM_COLOR']; /** * @private {!Array} List of capability types considered * monochrome. * @const */ this.MONOCHROME_TYPES_ = ['STANDARD_MONOCHROME', 'CUSTOM_MONOCHROME']; } /** @return {string} ID of the destination. */ get id() { return this.id_; } /** @return {!print_preview.DestinationType} Type of the destination. */ get type() { return this.type_; } /** * @return {!print_preview.DestinationOrigin} Origin of the destination. */ get origin() { return this.origin_; } /** @return {string} Display name of the destination. */ get displayName() { return this.displayName_; } /** @return {boolean} Whether the destination has been used recently. */ get isRecent() { return this.isRecent_; } /** * @param {boolean} isRecent Whether the destination has been used recently. */ set isRecent(isRecent) { this.isRecent_ = isRecent; } /** * @return {boolean} Whether the user owns the destination. Only applies to * cloud-based destinations. */ get isOwned() { return this.isOwned_; } /** * @return {string} Account this destination is registered for, if known. */ get account() { return this.account_; } /** @return {boolean} Whether the destination is local or cloud-based. */ get isLocal() { return this.origin_ == print_preview.DestinationOrigin.LOCAL || this.origin_ == print_preview.DestinationOrigin.EXTENSION || this.origin_ == print_preview.DestinationOrigin.CROS || (this.origin_ == print_preview.DestinationOrigin.PRIVET && this.connectionStatus_ != print_preview.DestinationConnectionStatus.UNREGISTERED); } /** @return {boolean} Whether the destination is a Privet local printer */ get isPrivet() { return this.origin_ == print_preview.DestinationOrigin.PRIVET; } /** * @return {boolean} Whether the destination is an extension managed * printer. */ get isExtension() { return this.origin_ == print_preview.DestinationOrigin.EXTENSION; } /** * @return {string} The location of the destination, or an empty string if * the location is unknown. */ get location() { if (this.location_ == null) { this.location_ = ''; this.tags_.some(tag => { return Destination.LOCATION_TAG_PREFIXES.some(prefix => { if (tag.startsWith(prefix)) { this.location_ = tag.substring(prefix.length) || ''; return true; } }); }); } return this.location_; } /** * @return {string} The description of the destination, or an empty string, * if it was not provided. */ get description() { return this.description_; } /** * @return {string} Most relevant string to help user to identify this * destination. */ get hint() { if (this.id_ == Destination.GooglePromotedId.DOCS) { return this.account_; } return this.location || this.extensionName || this.description; } /** @return {!Array} Tags associated with the destination. */ get tags() { return this.tags_.slice(0); } /** @return {string} Cloud ID associated with the destination */ get cloudID() { return this.cloudID_; } /** * @return {string} Extension ID associated with the destination. Non-empty * only for extension managed printers. */ get extensionId() { return this.extensionId_; } /** * @return {string} Extension name associated with the destination. * Non-empty only for extension managed printers. */ get extensionName() { return this.extensionName_; } /** @return {?print_preview.Cdd} Print capabilities of the destination. */ get capabilities() { return this.capabilities_; } /** * @param {?print_preview.Cdd} capabilities Print capabilities of the * destination. */ set capabilities(capabilities) { if (capabilities) this.capabilities_ = capabilities; } /** * @return {!print_preview.DestinationConnectionStatus} Connection status * of the print destination. */ get connectionStatus() { return this.connectionStatus_; } /** * @param {!print_preview.DestinationConnectionStatus} status Connection * status of the print destination. */ set connectionStatus(status) { this.connectionStatus_ = status; } /** @return {boolean} Whether the destination is considered offline. */ get isOffline() { return arrayContains( [ print_preview.DestinationConnectionStatus.OFFLINE, print_preview.DestinationConnectionStatus.DORMANT ], this.connectionStatus_); } /** @return {string} Human readable status for offline destination. */ get offlineStatusText() { if (!this.isOffline) { return ''; } const offlineDurationMs = Date.now() - this.lastAccessTime_; let offlineMessageId; if (offlineDurationMs > 31622400000.0) { // One year. offlineMessageId = 'offlineForYear'; } else if (offlineDurationMs > 2678400000.0) { // One month. offlineMessageId = 'offlineForMonth'; } else if (offlineDurationMs > 604800000.0) { // One week. offlineMessageId = 'offlineForWeek'; } else { offlineMessageId = 'offline'; } return loadTimeData.getString(offlineMessageId); } /** * @return {number} Number of milliseconds since the epoch when the printer * was last accessed. */ get lastAccessTime() { return this.lastAccessTime_; } /** @return {string} Relative URL of the destination's icon. */ get iconUrl() { if (this.id_ == Destination.GooglePromotedId.DOCS) { return Destination.IconUrl_.DOCS; } if (this.id_ == Destination.GooglePromotedId.SAVE_AS_PDF) { return Destination.IconUrl_.PDF; } if (this.isEnterprisePrinter) { return Destination.IconUrl_.ENTERPRISE; } if (this.isLocal) { return Destination.IconUrl_.LOCAL_1X; } if (this.type_ == print_preview.DestinationType.MOBILE && this.isOwned_) { return Destination.IconUrl_.MOBILE; } if (this.type_ == print_preview.DestinationType.MOBILE) { return Destination.IconUrl_.MOBILE_SHARED; } if (this.isOwned_) { return Destination.IconUrl_.CLOUD_1X; } return Destination.IconUrl_.CLOUD_SHARED_1X; } /** * @return {string} The srcset="" attribute of a destination. Generally used * for a 2x (e.g. HiDPI) icon. Can be empty or of the format ' 2x'. */ get srcSet() { let srcSetIcon = ''; let iconUrl = this.iconUrl; if (iconUrl == Destination.IconUrl_.LOCAL_1X) { srcSetIcon = Destination.IconUrl_.LOCAL_2X; } else if (iconUrl == Destination.IconUrl_.CLOUD_1X) { srcSetIcon = Destination.IconUrl_.CLOUD_2X; } else if (iconUrl == Destination.IconUrl_.CLOUD_SHARED_1X) { srcSetIcon = Destination.IconUrl_.CLOUD_SHARED_2X; } if (srcSetIcon) { srcSetIcon += ' 2x'; } return srcSetIcon; } /** * @return {!Array} Properties (besides display name) to match * search queries against. */ get extraPropertiesToMatch() { return [this.location, this.description]; } /** * Matches a query against the destination. * @param {!RegExp} query Query to match against the destination. * @return {boolean} {@code true} if the query matches this destination, * {@code false} otherwise. */ matches(query) { return !!this.displayName_.match(query) || !!this.extensionName_.match(query) || this.extraPropertiesToMatch.some(p => p.match(query)); } /** * Gets the destination's provisional type. * @return {print_preview.DestinationProvisionalType} */ get provisionalType() { return this.provisionalType_; } /** * Whether the destinaion is provisional. * @return {boolean} */ get isProvisional() { return this.provisionalType_ != print_preview.DestinationProvisionalType.NONE; } /** * Whether the printer is enterprise policy controlled printer. * @return {boolean} */ get isEnterprisePrinter() { return this.isEnterprisePrinter_; } /** * @return {Object} Color capability of this destination. * @private */ colorCapability_() { return this.capabilities && this.capabilities.printer && this.capabilities.printer.color ? this.capabilities.printer.color : null; } /** * @return {boolean} Whether the printer supports both black and white and * color printing. */ get hasColorCapability() { const capability = this.colorCapability_(); if (!capability || !capability.option) return false; let hasColor = false; let hasMonochrome = false; capability.option.forEach(option => { const type = assert(option.type); hasColor = hasColor || this.COLOR_TYPES_.includes(option.type); hasMonochrome = hasMonochrome || this.MONOCHROME_TYPES_.includes(option.type); }); return hasColor && hasMonochrome; } /** * @param {boolean} isColor Whether to use a color printing mode. * @return {Object} Selected color option. */ getSelectedColorOption(isColor) { const typesToLookFor = isColor ? this.COLOR_TYPES_ : this.MONOCHROME_TYPES_; const capability = this.colorCapability_(); if (!capability || !capability.option) return null; for (let i = 0; i < typesToLookFor.length; i++) { const matchingOptions = capability.option.filter(option => { return option.type == typesToLookFor[i]; }); if (matchingOptions.length > 0) return matchingOptions[0]; } return null; } /** * @param {boolean} isColor Whether to use a color printing mode. * @return {number} Native color model of the destination. */ getNativeColorModel(isColor) { // For non-local printers or printers without capability, native color // model is ignored. const capability = this.colorCapability_(); if (!capability || !capability.option || !this.isLocal) { return isColor ? print_preview.ColorMode.COLOR : print_preview.ColorMode.GRAY; } const selected = this.getSelectedColorOption(isColor); const mode = parseInt(selected ? selected.vendor_id : null, 10); if (isNaN(mode)) { return isColor ? print_preview.ColorMode.COLOR : print_preview.ColorMode.GRAY; } return mode; } /** * @return {Object} The default color option for the destination. */ get defaultColorOption() { const capability = this.colorCapability_(); if (!capability || !capability.option) return null; const defaultOptions = capability.option.filter(option => { return option.is_default; }); return defaultOptions.length != 0 ? defaultOptions[0] : null; } } /** * Prefix of the location destination tag. * @type {!Array} * @const */ Destination.LOCATION_TAG_PREFIXES = ['__cp__location=', '__cp__printer-location=']; /** * Enumeration of Google-promoted destination IDs. * @enum {string} */ Destination.GooglePromotedId = { DOCS: '__google__docs', SAVE_AS_PDF: 'Save as PDF' }; /** * Enumeration of relative icon URLs for various types of destinations. * @enum {string} * @private */ Destination.IconUrl_ = { CLOUD_1X: 'images/1x/printer.png', CLOUD_2X: 'images/2x/printer.png', CLOUD_SHARED_1X: 'images/1x/printer_shared.png', CLOUD_SHARED_2X: 'images/2x/printer_shared.png', LOCAL_1X: 'images/1x/printer.png', LOCAL_2X: 'images/2x/printer.png', MOBILE: 'images/mobile.png', MOBILE_SHARED: 'images/mobile_shared.png', THIRD_PARTY: 'images/third_party.png', PDF: 'images/pdf.png', DOCS: 'images/google_doc.png', ENTERPRISE: 'images/business.svg' }; // Export return { Destination: Destination, makeRecentDestination: makeRecentDestination, }; }); // // Copyright 2017 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('print_preview', function() { 'use strict'; /** * Converts DestinationOrigin to PrinterType. * @param {!print_preview.DestinationOrigin} origin The printer's * destination origin. * return {?print_preview.PrinterType} The corresponding PrinterType. * Returns null if no match is found. */ const originToType = function(origin) { if (origin === print_preview.DestinationOrigin.LOCAL || origin === print_preview.DestinationOrigin.CROS) { return print_preview.PrinterType.LOCAL_PRINTER; } if (origin === print_preview.DestinationOrigin.PRIVET) return print_preview.PrinterType.PRIVET_PRINTER; if (origin === print_preview.DestinationOrigin.EXTENSION) return print_preview.PrinterType.EXTENSION_PRINTER; return null; }; class DestinationMatch { /** * A set of key parameters describing a destination used to determine * if two destinations are the same. * @param {!Array} origins Match * destinations from these origins. * @param {RegExp} idRegExp Match destination's id. * @param {RegExp} displayNameRegExp Match destination's displayName. * @param {boolean} skipVirtualDestinations Whether to ignore virtual * destinations, for example, Save as PDF. */ constructor(origins, idRegExp, displayNameRegExp, skipVirtualDestinations) { /** @private {!Array} */ this.origins_ = origins; /** @private {RegExp} */ this.idRegExp_ = idRegExp; /** @private {RegExp} */ this.displayNameRegExp_ = displayNameRegExp; /** @private {boolean} */ this.skipVirtualDestinations_ = skipVirtualDestinations; } /** * @param {string} origin Origin to match. * @return {boolean} Whether the origin is one of the {@code origins_}. */ matchOrigin(origin) { return arrayContains(this.origins_, origin); } /** * @param {string} id Id of the destination. * @param {string} origin Origin of the destination. * @return {boolean} Whether destination is the same as initial. */ matchIdAndOrigin(id, origin) { return this.matchOrigin(origin) && !!this.idRegExp_ && this.idRegExp_.test(id); } /** * @param {!print_preview.Destination} destination Destination to match. * @return {boolean} Whether {@code destination} matches the last user * selected one. */ match(destination) { if (!this.matchOrigin(destination.origin)) { return false; } if (this.idRegExp_ && !this.idRegExp_.test(destination.id)) { return false; } if (this.displayNameRegExp_ && !this.displayNameRegExp_.test(destination.displayName)) { return false; } if (this.skipVirtualDestinations_ && this.isVirtualDestination_(destination)) { return false; } return true; } /** * @param {!print_preview.Destination} destination Destination to check. * @return {boolean} Whether {@code destination} is virtual, in terms of * destination selection. * @private */ isVirtualDestination_(destination) { if (destination.origin == print_preview.DestinationOrigin.LOCAL) { return arrayContains( [print_preview.Destination.GooglePromotedId.SAVE_AS_PDF], destination.id); } return arrayContains( [print_preview.Destination.GooglePromotedId.DOCS], destination.id); } /** * @return {?print_preview.PrinterType} The printer type of this * destination match. Will return null for Cloud destinations. */ getType() { return originToType(this.origins_[0]); } } // Export return {originToType: originToType, DestinationMatch: DestinationMatch}; }); // // 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('print_preview', function() { 'use strict'; /** * @param{!print_preview.PrinterType} type The type of printer to parse. * @param{!print_preview.LocalDestinationInfo | * !print_preview.PrivetPrinterDescription | * !print_preview.ProvisionalDestinationInfo} printer Information * about the printer. Type expected depends on |type|: * For LOCAL_PRINTER => print_preview.LocalDestinationInfo * For PRIVET_PRINTER => print_preview.PrivetPrinterDescription * For EXTENSION_PRINTER => print_preview.ProvisionalDestinationInfo * @return {!Array | !print_preview.Destination} */ function parseDestination(type, printer) { if (type === print_preview.PrinterType.LOCAL_PRINTER) { return parseLocalDestination( /** @type {!print_preview.LocalDestinationInfo} */ (printer)); } if (type === print_preview.PrinterType.PRIVET_PRINTER) { return parsePrivetDestination( /** @type {!print_preview.PrivetPrinterDescription} */ (printer)); } if (type === print_preview.PrinterType.EXTENSION_PRINTER) { return parseExtensionDestination( /** @type {!print_preview.ProvisionalDestinationInfo} */ (printer)); } assertNotReached('Unknown printer type ' + type); return []; } /** * Parses a local print destination. * @param {!print_preview.LocalDestinationInfo} destinationInfo Information * describing a local print destination. * @return {!print_preview.Destination} Parsed local print destination. */ function parseLocalDestination(destinationInfo) { const options = { description: destinationInfo.printerDescription, isEnterprisePrinter: destinationInfo.cupsEnterprisePrinter }; if (destinationInfo.printerOptions) { // Convert options into cloud print tags format. options.tags = Object.keys(destinationInfo.printerOptions).map(function(key) { return '__cp__' + key + '=' + this[key]; }, destinationInfo.printerOptions); } return new print_preview.Destination( destinationInfo.deviceName, print_preview.DestinationType.LOCAL, cr.isChromeOS ? print_preview.DestinationOrigin.CROS : print_preview.DestinationOrigin.LOCAL, destinationInfo.printerName, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, options); } /** * Parses a privet destination as one or more local printers. * @param {!print_preview.PrivetPrinterDescription} destinationInfo Object * that describes a privet printer. * @return {!print_preview.Destination | * !Array} Parsed destination info. */ function parsePrivetDestination(destinationInfo) { const returnedPrinters = []; if (destinationInfo.hasLocalPrinting) { returnedPrinters.push(new print_preview.Destination( destinationInfo.serviceName, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.PRIVET, destinationInfo.name, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, {cloudID: destinationInfo.cloudID})); } if (destinationInfo.isUnregistered) { returnedPrinters.push(new print_preview.Destination( destinationInfo.serviceName, print_preview.DestinationType.GOOGLE, print_preview.DestinationOrigin.PRIVET, destinationInfo.name, false /*isRecent*/, print_preview.DestinationConnectionStatus.UNREGISTERED)); } return returnedPrinters.length === 1 ? returnedPrinters[0] : returnedPrinters; } /** * Parses an extension destination from an extension supplied printer * description. * @param {!print_preview.ProvisionalDestinationInfo} destinationInfo Object * describing an extension printer. * @return {!print_preview.Destination} Parsed destination. */ function parseExtensionDestination(destinationInfo) { const provisionalType = destinationInfo.provisional ? print_preview.DestinationProvisionalType.NEEDS_USB_PERMISSION : print_preview.DestinationProvisionalType.NONE; return new print_preview.Destination( destinationInfo.id, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.EXTENSION, destinationInfo.name, false /* isRecent */, print_preview.DestinationConnectionStatus.ONLINE, { description: destinationInfo.description || '', extensionId: destinationInfo.extensionId, extensionName: destinationInfo.extensionName || '', provisionalType: provisionalType }); } // Export return { parseDestination: parseDestination, parseExtensionDestination: parseExtensionDestination }; }); // // 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('cloudprint', function() { 'use strict'; /** * Enumeration of cloud destination field names. * @enum {string} */ const CloudDestinationField = { CAPABILITIES: 'capabilities', CONNECTION_STATUS: 'connectionStatus', DESCRIPTION: 'description', DISPLAY_NAME: 'displayName', ID: 'id', LAST_ACCESS: 'accessTime', TAGS: 'tags', TYPE: 'type' }; /** * Special tag that denotes whether the destination has been recently used. * @const {string} */ const RECENT_TAG = '^recent'; /** * Special tag that denotes whether the destination is owned by the user. * @const {string} */ const OWNED_TAG = '^own'; /** * Enumeration of cloud destination types that are supported by print preview. * @enum {string} */ const DestinationCloudType = { ANDROID: 'ANDROID_CHROME_SNAPSHOT', DOCS: 'DOCS', IOS: 'IOS_CHROME_SNAPSHOT' }; /** * Parses the destination type. * @param {string} typeStr Destination type given by the Google Cloud Print * server. * @return {!print_preview.DestinationType} Destination type. * @private */ function parseType(typeStr) { if (typeStr == DestinationCloudType.ANDROID || typeStr == DestinationCloudType.IOS) { return print_preview.DestinationType.MOBILE; } if (typeStr == DestinationCloudType.DOCS) { return print_preview.DestinationType.GOOGLE_PROMOTED; } return print_preview.DestinationType.GOOGLE; } /** * Parses a destination from JSON from a Google Cloud Print search or printer * response. * @param {!Object} json Object that represents a Google Cloud Print search or * printer response. * @param {!print_preview.DestinationOrigin} origin The origin of the * response. * @param {string} account The account this destination is registered for or * empty string, if origin != COOKIES. * @return {!print_preview.Destination} Parsed destination. */ function parseCloudDestination(json, origin, account) { if (!json.hasOwnProperty(CloudDestinationField.ID) || !json.hasOwnProperty(CloudDestinationField.TYPE) || !json.hasOwnProperty(CloudDestinationField.DISPLAY_NAME)) { throw Error('Cloud destination does not have an ID or a display name'); } const id = json[CloudDestinationField.ID]; const tags = json[CloudDestinationField.TAGS] || []; const connectionStatus = json[CloudDestinationField.CONNECTION_STATUS] || print_preview.DestinationConnectionStatus.UNKNOWN; const optionalParams = { account: account, tags: tags, isOwned: arrayContains(tags, OWNED_TAG), lastAccessTime: parseInt(json[CloudDestinationField.LAST_ACCESS], 10) || Date.now(), cloudID: id, description: json[CloudDestinationField.DESCRIPTION] }; const cloudDest = new print_preview.Destination( id, parseType(json[CloudDestinationField.TYPE]), origin, json[CloudDestinationField.DISPLAY_NAME], arrayContains(tags, RECENT_TAG) /*isRecent*/, connectionStatus, optionalParams); if (json.hasOwnProperty(CloudDestinationField.CAPABILITIES)) { cloudDest.capabilities = /** @type {!print_preview.Cdd} */ ( json[CloudDestinationField.CAPABILITIES]); } return cloudDest; } /** * Enumeration of invitation field names. * @enum {string} */ const InvitationField = { PRINTER: 'printer', RECEIVER: 'receiver', SENDER: 'sender' }; /** * Enumeration of cloud destination types that are supported by print preview. * @enum {string} */ const InvitationAclType = {DOMAIN: 'DOMAIN', GROUP: 'GROUP', PUBLIC: 'PUBLIC', USER: 'USER'}; /** * Parses printer sharing invitation from JSON from GCP invite API response. * @param {!Object} json Object that represents a invitation search response. * @param {string} account The account this invitation is sent for. * @return {!print_preview.Invitation} Parsed invitation. */ function parseInvitation(json, account) { if (!json.hasOwnProperty(InvitationField.SENDER) || !json.hasOwnProperty(InvitationField.RECEIVER) || !json.hasOwnProperty(InvitationField.PRINTER)) { throw Error('Invitation does not have necessary info.'); } const nameFormatter = function(name, scope) { return name && scope ? (name + ' (' + scope + ')') : (name || scope); }; const sender = json[InvitationField.SENDER]; const senderName = nameFormatter(sender['name'], sender['email']); const receiver = json[InvitationField.RECEIVER]; let receiverName = ''; const receiverType = receiver['type']; if (receiverType == InvitationAclType.USER) { // It's a personal invitation, empty name indicates just that. } else if ( receiverType == InvitationAclType.GROUP || receiverType == InvitationAclType.DOMAIN) { receiverName = nameFormatter(receiver['name'], receiver['scope']); } else { throw Error('Invitation of unsupported receiver type'); } const destination = cloudprint.parseCloudDestination( json[InvitationField.PRINTER], print_preview.DestinationOrigin.COOKIES, account); return new print_preview.Invitation( senderName, receiverName, destination, receiver, account); } // Export return { parseCloudDestination: parseCloudDestination, parseInvitation: parseInvitation, }; }); // // 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.exportPath('print_preview'); /** * Printer search statuses used by the destination store. * @enum {string} */ print_preview.DestinationStorePrinterSearchStatus = { START: 'start', SEARCHING: 'searching', DONE: 'done' }; cr.define('print_preview', function() { 'use strict'; /** * Localizes printer capabilities. * @param {!print_preview.Cdd} capabilities Printer capabilities to * localize. * @return {!print_preview.Cdd} Localized capabilities. */ const localizeCapabilities = function(capabilities) { if (!capabilities.printer) return capabilities; const mediaSize = capabilities.printer.media_size; if (!mediaSize) return capabilities; for (let i = 0, media; (media = mediaSize.option[i]); i++) { // No need to patch capabilities with localized names provided. if (!media.custom_display_name_localized) { media.custom_display_name = media.custom_display_name || DestinationStore.MEDIA_DISPLAY_NAMES_[media.name] || media.name; } } return capabilities; }; /** * Compare two media sizes by their names. * @param {!Object} a Media to compare. * @param {!Object} b Media to compare. * @return {number} 1 if a > b, -1 if a < b, or 0 if a == b. */ const compareMediaNames = function(a, b) { const nameA = a.custom_display_name_localized || a.custom_display_name; const nameB = b.custom_display_name_localized || b.custom_display_name; return nameA == nameB ? 0 : (nameA > nameB ? 1 : -1); }; /** * Sort printer media sizes. * @param {!print_preview.Cdd} capabilities Printer capabilities to * localize. * @return {!print_preview.Cdd} Localized capabilities. * @private */ const sortMediaSizes = function(capabilities) { if (!capabilities.printer) return capabilities; const mediaSize = capabilities.printer.media_size; if (!mediaSize) return capabilities; // For the standard sizes, separate into categories, as seen in the Cloud // Print CDD guide: // - North American // - Chinese // - ISO // - Japanese // - Other metric // Otherwise, assume they are custom sizes. const categoryStandardNA = []; const categoryStandardCN = []; const categoryStandardISO = []; const categoryStandardJP = []; const categoryStandardMisc = []; const categoryCustom = []; for (let i = 0, media; (media = mediaSize.option[i]); i++) { const name = media.name || 'CUSTOM'; let category; if (name.startsWith('NA_')) { category = categoryStandardNA; } else if ( name.startsWith('PRC_') || name.startsWith('ROC_') || name == 'OM_DAI_PA_KAI' || name == 'OM_JUURO_KU_KAI' || name == 'OM_PA_KAI') { category = categoryStandardCN; } else if (name.startsWith('ISO_')) { category = categoryStandardISO; } else if (name.startsWith('JIS_') || name.startsWith('JPN_')) { category = categoryStandardJP; } else if (name.startsWith('OM_')) { category = categoryStandardMisc; } else { assert(name == 'CUSTOM', 'Unknown media size. Assuming custom'); category = categoryCustom; } category.push(media); } // For each category, sort by name. categoryStandardNA.sort(compareMediaNames); categoryStandardCN.sort(compareMediaNames); categoryStandardISO.sort(compareMediaNames); categoryStandardJP.sort(compareMediaNames); categoryStandardMisc.sort(compareMediaNames); categoryCustom.sort(compareMediaNames); // Then put it all back together. mediaSize.option = categoryStandardNA; mediaSize.option.push( ...categoryStandardCN, ...categoryStandardISO, ...categoryStandardJP, ...categoryStandardMisc, ...categoryCustom); return capabilities; }; class DestinationStore extends cr.EventTarget { /** * A data store that stores destinations and dispatches events when the * data store changes. * @param {!print_preview.UserInfo} userInfo User information repository. * @param {!WebUIListenerTracker} listenerTracker Tracker for WebUI * listeners added in DestinationStore constructor. */ constructor(userInfo, listenerTracker) { super(); /** * Used to fetch local print destinations. * @private {!print_preview.NativeLayer} */ this.nativeLayer_ = print_preview.NativeLayer.getInstance(); /** * User information repository. * @private {!print_preview.UserInfo} */ this.userInfo_ = userInfo; /** * Used to track metrics. * @private {!print_preview.DestinationSearchMetricsContext} */ this.metrics_ = new print_preview.DestinationSearchMetricsContext(); /** * Internal backing store for the data store. * @private {!Array} */ this.destinations_ = []; /** * Cache used for constant lookup of destinations by origin and id. * @private {Object} */ this.destinationMap_ = {}; /** * Currently selected destination. * @private {print_preview.Destination} */ this.selectedDestination_ = null; /** * Whether the destination store will auto select the destination that * matches this set of parameters. * @private {print_preview.DestinationMatch} */ this.autoSelectMatchingDestination_ = null; /** * Event tracker used to track event listeners of the destination store. * @private {!EventTracker} */ this.tracker_ = new EventTracker(); /** * Whether PDF printer is enabled. It's disabled, for example, in App * Kiosk mode. * @private {boolean} */ this.pdfPrinterEnabled_ = false; /** * ID of the system default destination. * @private {string} */ this.systemDefaultDestinationId_ = ''; /** * Used to fetch cloud-based print destinations. * @private {cloudprint.CloudPrintInterface} */ this.cloudPrintInterface_ = null; /** * Maps user account to the list of origins for which destinations are * already loaded. * @private {!Object>} */ this.loadedCloudOrigins_ = {}; /** * ID of a timeout after the initial destination ID is set. If no inserted * destination matches the initial destination ID after the specified * timeout, the first destination in the store will be automatically * selected. * @private {?number} */ this.autoSelectTimeout_ = null; /** * Whether a search for destinations is in progress for each type of * printer. * @private {!Map} */ this.destinationSearchStatus_ = new Map([ [ print_preview.PrinterType.EXTENSION_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ], [ print_preview.PrinterType.PRIVET_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ], [ print_preview.PrinterType.LOCAL_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ] ]); /** * MDNS service name of destination that we are waiting to register. * @private {?string} */ this.waitForRegisterDestination_ = null; /** * Local destinations are CROS destinations on ChromeOS because they * require extra setup. * @private {!print_preview.DestinationOrigin} */ this.platformOrigin_ = cr.isChromeOS ? print_preview.DestinationOrigin.CROS : print_preview.DestinationOrigin.LOCAL; /** * Whether to default to the system default printer instead of the most * recent destination. * @private {boolean} */ this.useSystemDefaultAsDefault_ = loadTimeData.getBoolean('useSystemDefaultPrinter'); /** * The recent print destinations, set when the store is initialized. * @private {!Array} */ this.recentDestinations_ = []; this.reset_(); this.addWebUIEventListeners_(listenerTracker); } /** * @param {?string=} opt_account Account to filter destinations by. When * null or omitted, all destinations are returned. * @return {!Array} List of destinations * accessible by the {@code account}. */ destinations(opt_account) { if (opt_account) { return this.destinations_.filter(function(destination) { return !destination.account || destination.account == opt_account; }); } return this.destinations_.slice(0); } /** * Gets the destination, if any, matching |account|, |id|, and |origin| in * the destination map. * @param {!print_preview.DestinationOrigin} origin The origin of the * destination. * @param {string} id The destination ID * @param {string} account The account the destination is associated with. * @return {?print_preview.Destination} */ getDestination(origin, id, account) { return this.destinationMap_[this.getDestinationKey_(origin, id, account)]; } /** * @return {print_preview.Destination} The currently selected destination or * {@code null} if none is selected. */ get selectedDestination() { return this.selectedDestination_; } /** @return {boolean} Whether destination selection is pending or not. */ get isAutoSelectDestinationInProgress() { return this.selectedDestination_ == null && this.autoSelectTimeout_ != null; } /** * @return {boolean} Whether a search for print destinations is in progress. */ get isPrintDestinationSearchInProgress() { let isLocalDestinationSearchInProgress = Array.from(this.destinationSearchStatus_.values()) .some( el => el === print_preview.DestinationStorePrinterSearchStatus .SEARCHING); if (isLocalDestinationSearchInProgress) return true; let isCloudDestinationSearchInProgress = !!this.cloudPrintInterface_ && this.cloudPrintInterface_.isCloudDestinationSearchInProgress; return isCloudDestinationSearchInProgress; } /** * Starts listening for relevant WebUI events and adds the listeners to * |listenerTracker|. |listenerTracker| is responsible for removing the * listeners when necessary. * @param {!WebUIListenerTracker} listenerTracker * @private */ addWebUIEventListeners_(listenerTracker) { listenerTracker.add('printers-added', this.onPrintersAdded_.bind(this)); listenerTracker.add( 'reload-printer-list', this.onDestinationsReload.bind(this)); } /** * @param {(?print_preview.Destination | * ?print_preview.RecentDestination)} destination * @return {boolean} Whether the destination is valid. */ isDestinationValid(destination) { return !!destination && !!destination.id && !!destination.origin; } /** * Initializes the destination store. Sets the initially selected * destination. If any inserted destinations match this ID, that destination * will be automatically selected. * @param {boolean} isInAppKioskMode Whether the print preview is in App * Kiosk mode. * @param {string} systemDefaultDestinationId ID of the system default * destination. * @param {?string} serializedDefaultDestinationSelectionRulesStr Serialized * default destination selection rules. * @param {!Array} * recentDestinations The recent print destinations. */ init( isInAppKioskMode, systemDefaultDestinationId, serializedDefaultDestinationSelectionRulesStr, recentDestinations) { this.pdfPrinterEnabled_ = !isInAppKioskMode; this.systemDefaultDestinationId_ = systemDefaultDestinationId; this.createLocalPdfPrintDestination_(); const isRecentDestinationValid = recentDestinations.length > 0 && this.isDestinationValid(recentDestinations[0]); if (!isRecentDestinationValid) { const destinationMatch = this.convertToDestinationMatch_( serializedDefaultDestinationSelectionRulesStr); if (destinationMatch) { this.fetchMatchingDestination_(destinationMatch); return; } } if (this.systemDefaultDestinationId_.length == 0 && !isRecentDestinationValid) { this.selectPdfDestination_(); return; } this.recentDestinations_ = recentDestinations; let origin = null; let id = ''; let account = ''; let name = ''; let capabilities = null; let extensionId = ''; let extensionName = ''; let foundDestination = false; // Run through the destinations forward. As soon as we find a // destination, don't select any future destinations, just mark // them recent. Otherwise, there is a race condition between selecting // destinations/updating the print ticket and this selecting a new // destination that causes random print preview errors. for (let destination of recentDestinations) { origin = destination.origin; id = destination.id; account = destination.account || ''; name = destination.displayName || ''; capabilities = destination.capabilities; extensionId = destination.extensionId || ''; extensionName = destination.extensionName || ''; const candidate = this.destinationMap_[this.getDestinationKey_(origin, id, account)]; if (candidate != null) { candidate.isRecent = true; if (!foundDestination && !this.useSystemDefaultAsDefault_) this.selectDestination(candidate); foundDestination = true; } else if (!foundDestination && !this.useSystemDefaultAsDefault_) { foundDestination = this.fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName); } } if (foundDestination && !this.useSystemDefaultAsDefault_) return; // Try the system default id = this.systemDefaultDestinationId_; origin = id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? print_preview.DestinationOrigin.LOCAL : this.platformOrigin_; account = ''; const systemDefaultCandidate = this.destinationMap_[this.getDestinationKey_(origin, id, account)]; if (systemDefaultCandidate != null) { this.selectDestination(systemDefaultCandidate); return; } if (this.fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName)) { return; } this.selectPdfDestination_(); } /** * Attempts to fetch capabilities of the destination identified by the * provided origin, id and account. * @param {print_preview.DestinationOrigin} origin Destination * origin. * @param {string} id Destination id. * @param {string} account User account destination is registered for. * @param {string} name Destination display name. * @param {?print_preview.Cdd} capabilities Destination capabilities. * @param {string} extensionId Extension ID associated with this * destination. * @param {string} extensionName Extension name associated with this * destination. * @return {boolean} Whether capabilities fetch was successfully started. * @private */ fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName) { this.autoSelectMatchingDestination_ = this.createExactDestinationMatch_(origin, id); const type = print_preview.originToType(origin); if (type == print_preview.PrinterType.LOCAL_PRINTER) { this.nativeLayer_.getPrinterCapabilities(id, type).then( this.onCapabilitiesSet_.bind(this, origin, id), this.onGetCapabilitiesFail_.bind(this, origin, id)); return true; } if (this.cloudPrintInterface_ && (origin == print_preview.DestinationOrigin.COOKIES || origin == print_preview.DestinationOrigin.DEVICE)) { this.cloudPrintInterface_.printer(id, origin, account); return true; } if (origin == print_preview.DestinationOrigin.PRIVET || origin == print_preview.DestinationOrigin.EXTENSION) { // TODO(noamsml): Resolve a specific printer instead of listing all // privet or extension printers in this case. this.startLoadDestinations(type); // Create a fake selectedDestination_ that is not actually in the // destination store. When the real destination is created, this // destination will be overwritten. const params = (origin === print_preview.DestinationOrigin.PRIVET) ? {} : { description: '', extensionId: extensionId, extensionName: extensionName, provisionalType: print_preview.DestinationProvisionalType.NONE }; this.selectedDestination_ = new print_preview.Destination( id, print_preview.DestinationType.LOCAL, origin, name, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, params); if (capabilities) { this.selectedDestination_.capabilities = capabilities; cr.dispatchSimpleEvent( this, DestinationStore.EventType .CACHED_SELECTED_DESTINATION_INFO_READY); } return true; } return false; } /** * Attempts to find a destination matching the provided rules. * @param {!print_preview.DestinationMatch} destinationMatch Rules to match. * @private */ fetchMatchingDestination_(destinationMatch) { this.autoSelectMatchingDestination_ = destinationMatch; const type = destinationMatch.getType(); if (type != null) { // Local, Privet, or Extension. this.startLoadDestinations(type); } else if ( destinationMatch.matchOrigin( print_preview.DestinationOrigin.COOKIES) || destinationMatch.matchOrigin( print_preview.DestinationOrigin.DEVICE)) { this.startLoadCloudDestinations(); } } /** * @param {?string} serializedDefaultDestinationSelectionRulesStr Serialized * default destination selection rules. * @return {?print_preview.DestinationMatch} Creates rules matching * previously selected destination. * @private */ convertToDestinationMatch_(serializedDefaultDestinationSelectionRulesStr) { let matchRules = null; try { if (serializedDefaultDestinationSelectionRulesStr) { matchRules = JSON.parse(serializedDefaultDestinationSelectionRulesStr); } } catch (e) { console.error('Failed to parse defaultDestinationSelectionRules: ' + e); } if (!matchRules) return null; const isLocal = !matchRules.kind || matchRules.kind == 'local'; const isCloud = !matchRules.kind || matchRules.kind == 'cloud'; if (!isLocal && !isCloud) { console.error('Unsupported type: "' + matchRules.kind + '"'); return null; } const origins = []; if (isLocal) { origins.push(print_preview.DestinationOrigin.LOCAL); origins.push(print_preview.DestinationOrigin.PRIVET); origins.push(print_preview.DestinationOrigin.EXTENSION); origins.push(print_preview.DestinationOrigin.CROS); } if (isCloud) { origins.push(print_preview.DestinationOrigin.COOKIES); origins.push(print_preview.DestinationOrigin.DEVICE); } let idRegExp = null; try { if (matchRules.idPattern) { idRegExp = new RegExp(matchRules.idPattern || '.*'); } } catch (e) { console.error('Failed to parse regexp for "id": ' + e); } let displayNameRegExp = null; try { if (matchRules.namePattern) { displayNameRegExp = new RegExp(matchRules.namePattern || '.*'); } } catch (e) { console.error('Failed to parse regexp for "name": ' + e); } return new print_preview.DestinationMatch( origins, idRegExp, displayNameRegExp, true /*skipVirtualDestinations*/); } /** * @return {print_preview.DestinationMatch} Creates rules matching * previously selected destination. * @private */ convertPreselectedToDestinationMatch_() { if (this.isDestinationValid(this.selectedDestination_)) { return this.createExactDestinationMatch_( this.selectedDestination_.origin, this.selectedDestination_.id); } if (this.systemDefaultDestinationId_.length > 0) { return this.createExactDestinationMatch_( this.platformOrigin_, this.systemDefaultDestinationId_); } return null; } /** * @param {string | print_preview.DestinationOrigin} origin Destination * origin. * @param {string} id Destination id. * @return {!print_preview.DestinationMatch} Creates rules matching * provided destination. * @private */ createExactDestinationMatch_(origin, id) { return new print_preview.DestinationMatch( [origin], new RegExp('^' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), null /*displayNameRegExp*/, false /*skipVirtualDestinations*/); } /** * Sets the destination store's Google Cloud Print interface. * @param {!cloudprint.CloudPrintInterface} cloudPrintInterface Interface * to set. */ setCloudPrintInterface(cloudPrintInterface) { assert(this.cloudPrintInterface_ == null); this.cloudPrintInterface_ = cloudPrintInterface; this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SEARCH_DONE, this.onCloudPrintSearchDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SEARCH_FAILED, this.onCloudPrintSearchDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PRINTER_DONE, this.onCloudPrintPrinterDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PRINTER_FAILED, this.onCloudPrintPrinterFailed_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PROCESS_INVITE_DONE, this.onCloudPrintProcessInviteDone_.bind(this)); } /** * @param {print_preview.Destination} destination Destination to select. */ selectDestination(destination) { this.autoSelectMatchingDestination_ = null; // When auto select expires, DESTINATION_SELECT event has to be dispatched // anyway (see isAutoSelectDestinationInProgress() logic). if (this.autoSelectTimeout_) { clearTimeout(this.autoSelectTimeout_); this.autoSelectTimeout_ = null; } else if (destination == this.selectedDestination_) { return; } if (destination == null) { this.selectedDestination_ = null; cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SELECT); return; } assert( !destination.isProvisional, 'Unable to select provisonal destinations'); // Update and persist selected destination. this.selectedDestination_ = destination; this.selectedDestination_.isRecent = true; // Adjust metrics. if (destination.cloudID && this.destinations_.some(function(otherDestination) { return otherDestination.cloudID == destination.cloudID && otherDestination != destination; })) { this.metrics_.record( destination.isPrivet ? print_preview.Metrics.DestinationSearchBucket .PRIVET_DUPLICATE_SELECTED : print_preview.Metrics.DestinationSearchBucket .CLOUD_DUPLICATE_SELECTED); } // Notify about selected destination change. cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SELECT); // Request destination capabilities from backend, since they are not // known yet. if (destination.capabilities == null) { const type = print_preview.originToType(destination.origin); if (type !== null) { this.nativeLayer_.getPrinterCapabilities(destination.id, type) .then( (caps) => this.onCapabilitiesSet_( destination.origin, destination.id, caps), () => this.onGetCapabilitiesFail_( destination.origin, destination.origin)); } else { assert( this.cloudPrintInterface_ != null, 'Cloud destination selected, but GCP is not enabled'); this.cloudPrintInterface_.printer( destination.id, destination.origin, destination.account); } } else { cr.dispatchSimpleEvent( this, DestinationStore.EventType.SELECTED_DESTINATION_CAPABILITIES_READY); } } /** * Attempt to resolve the capabilities for a Chrome OS printer. * @param {!print_preview.Destination} destination The destination which * requires resolution. * @return {!Promise} */ resolveCrosDestination(destination) { assert(destination.origin == print_preview.DestinationOrigin.CROS); return this.nativeLayer_.setupPrinter(destination.id); } /** * Attempts to resolve a provisional destination. * @param {!print_preview.Destination} destination Provisional destination * that should be resolved. */ resolveProvisionalDestination(destination) { assert( destination.provisionalType == print_preview.DestinationProvisionalType.NEEDS_USB_PERMISSION, 'Provisional type cannot be resolved.'); this.nativeLayer_.grantExtensionPrinterAccess(destination.id) .then( destinationInfo => { /** * Removes the destination from the store and replaces it with a * destination created from the resolved destination properties, * if any are reported. Then sends a * PROVISIONAL_DESTINATION_RESOLVED event. */ this.removeProvisionalDestination_(destination.id); const parsedDestination = print_preview.parseExtensionDestination(destinationInfo); this.insertIntoStore_(parsedDestination); this.dispatchProvisionalDestinationResolvedEvent_( destination.id, parsedDestination); }, () => { /** * The provisional destination is removed from the store and a * PROVISIONAL_DESTINATION_RESOLVED event is dispatched with a * null destination. */ this.removeProvisionalDestination_(destination.id); this.dispatchProvisionalDestinationResolvedEvent_( destination.id, null); }); } /** * Selects 'Save to PDF' destination (since it always exists). * @private */ selectPdfDestination_() { const saveToPdfKey = this.getDestinationKey_( print_preview.DestinationOrigin.LOCAL, print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, ''); this.selectDestination( this.destinationMap_[saveToPdfKey] || this.destinations_[0] || null); } /** * Attempts to select system default destination with a fallback to * 'Save to PDF' destination. * @private */ selectDefaultDestination_() { if (this.systemDefaultDestinationId_.length > 0) { if (this.autoSelectMatchingDestination_ && !this.autoSelectMatchingDestination_.matchIdAndOrigin( this.systemDefaultDestinationId_, this.platformOrigin_)) { if (this.fetchPreselectedDestination_( this.platformOrigin_, this.systemDefaultDestinationId_, '' /*account*/, '' /*name*/, null /*capabilities*/, '' /*extensionId*/, '' /*extensionName*/)) { return; } } } this.selectPdfDestination_(); } /** * Initiates loading of destinations. * @param{print_preview.PrinterType} type The type of destinations to load. */ startLoadDestinations(type) { if (this.destinationSearchStatus_.get(type) === print_preview.DestinationStorePrinterSearchStatus.DONE) { return; } this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.SEARCHING); this.nativeLayer_.getPrinters(type).then( this.onDestinationSearchDone_.bind(this, type), () => { // Will be rejected by C++ for privet printers if privet printing // is disabled. assert(type === print_preview.PrinterType.PRIVET_PRINTER); this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.DONE); }); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_STARTED); } /** * Initiates loading of cloud destinations. * @param {print_preview.DestinationOrigin=} opt_origin Search destinations * for the specified origin only. */ startLoadCloudDestinations(opt_origin) { if (this.cloudPrintInterface_ != null) { const origins = this.loadedCloudOrigins_[this.userInfo_.activeUser] || []; if (origins.length == 0 || (opt_origin && origins.indexOf(opt_origin) < 0)) { this.cloudPrintInterface_.search( this.userInfo_.activeUser, opt_origin); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_STARTED); } } } /** Requests load of COOKIE based cloud destinations. */ reloadUserCookieBasedDestinations() { const origins = this.loadedCloudOrigins_[this.userInfo_.activeUser] || []; if (origins.indexOf(print_preview.DestinationOrigin.COOKIES) >= 0) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); } else { this.startLoadCloudDestinations( print_preview.DestinationOrigin.COOKIES); } } /** Initiates loading of all known destination types. */ startLoadAllDestinations() { this.startLoadCloudDestinations(); for (const printerType of Object.values(print_preview.PrinterType)) { if (printerType !== print_preview.PrinterType.PDF_PRINTER) this.startLoadDestinations(printerType); } } /** * Wait for a privet device to be registered. */ waitForRegister(id) { const privetType = print_preview.PrinterType.PRIVET_PRINTER; this.nativeLayer_.getPrinters(privetType) .then(this.onDestinationSearchDone_.bind(this, privetType)); this.waitForRegisterDestination_ = id; } /** * Removes the provisional destination with ID |provisionalId| from * |destinationMap_| and |destinations_|. * @param{string} provisionalId The provisional destination ID. * @private */ removeProvisionalDestination_(provisionalId) { this.destinations_ = this.destinations_.filter( function(el) { if (el.id == provisionalId) { delete this.destinationMap_[this.getKey_(el)]; return false; } return true; }, this); } /** * Dispatches the PROVISIONAL_DESTINATION_RESOLVED event for id * |provisionalId| and destination |destination|. * @param {string} provisionalId The ID of the destination that was * resolved. * @param {?print_preview.Destination} destination Information about the * destination if it was resolved successfully. */ dispatchProvisionalDestinationResolvedEvent_(provisionalId, destination) { const event = new Event( DestinationStore.EventType.PROVISIONAL_DESTINATION_RESOLVED); event.provisionalId = provisionalId; event.destination = destination; this.dispatchEvent(event); } /** * Inserts {@code destination} to the data store and dispatches a * DESTINATIONS_INSERTED event. * @param {!print_preview.Destination} destination Print destination to * insert. * @private */ insertDestination_(destination) { if (this.insertIntoStore_(destination)) { this.destinationsInserted_(destination); } } /** * Inserts multiple {@code destinations} to the data store and dispatches * single DESTINATIONS_INSERTED event. * @param {!Array>} destinations Print * destinations to insert. * @private */ insertDestinations_(destinations) { let inserted = false; destinations.forEach(destination => { if (Array.isArray(destination)) { // privet printers return arrays of 1 or 2 printers inserted = destination.reduce(function(soFar, d) { return this.insertIntoStore_(d) || soFar; }, inserted); } else { inserted = this.insertIntoStore_(destination) || inserted; } }); if (inserted) { this.destinationsInserted_(); } } /** * Dispatches DESTINATIONS_INSERTED event. In auto select mode, tries to * update selected destination to match * {@code autoSelectMatchingDestination_}. * @param {print_preview.Destination=} opt_destination The only destination * that was changed or skipped if possibly more than one destination was * changed. Used as a hint to limit destination search scope against * {@code autoSelectMatchingDestination_}. */ destinationsInserted_(opt_destination) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATIONS_INSERTED); if (this.autoSelectMatchingDestination_) { const destinationsToSearch = opt_destination && [opt_destination] || this.destinations_; destinationsToSearch.some(function(destination) { if (this.autoSelectMatchingDestination_.match(destination)) { this.selectDestination(destination); return true; } }, this); } } /** * Updates an existing print destination with capabilities and display name * information. If the destination doesn't already exist, it will be added. * @param {!print_preview.Destination} destination Destination to update. * @private */ updateDestination_(destination) { assert(destination.constructor !== Array, 'Single printer expected'); destination.capabilities_ = localizeCapabilities(assert(destination.capabilities_)); if (print_preview.originToType(destination.origin) !== print_preview.PrinterType.LOCAL_PRINTER) { destination.capabilities_ = sortMediaSizes(destination.capabilities_); } const existingDestination = this.destinationMap_[this.getKey_(destination)]; if (existingDestination != null) { existingDestination.capabilities = destination.capabilities; } else { this.insertDestination_(destination); } if (this.selectedDestination_ && (existingDestination == this.selectedDestination_ || destination == this.selectedDestination_)) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.SELECTED_DESTINATION_CAPABILITIES_READY); } } /** * Called when loading of extension managed printers is done. * @private */ endExtensionPrinterSearch_() { // Clear initially selected (cached) extension destination if it hasn't // been found among reported extension destinations. if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchOrigin( print_preview.DestinationOrigin.EXTENSION) && this.selectedDestination_ && this.selectedDestination_.isExtension) { this.selectDefaultDestination_(); } } /** * Inserts a destination into the store without dispatching any events. * @param {!print_preview.Destination} destination The destination to be * inserted. * @return {boolean} Whether the inserted destination was not already in the * store. * @private */ insertIntoStore_(destination) { const key = this.getKey_(destination); const existingDestination = this.destinationMap_[key]; if (existingDestination == null) { destination.isRecent |= this.recentDestinations_.some(function(recent) { return ( destination.id == recent.id && destination.origin == recent.origin); }, this); this.destinations_.push(destination); this.destinationMap_[key] = destination; return true; } if (existingDestination.connectionStatus == print_preview.DestinationConnectionStatus.UNKNOWN && destination.connectionStatus != print_preview.DestinationConnectionStatus.UNKNOWN) { existingDestination.connectionStatus = destination.connectionStatus; return true; } return false; } /** * Creates a local PDF print destination. * @private */ createLocalPdfPrintDestination_() { // TODO(alekseys): Create PDF printer in the native code and send its // capabilities back with other local printers. if (this.pdfPrinterEnabled_) { this.insertDestination_(new print_preview.Destination( print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.LOCAL, loadTimeData.getString('printToPDF'), false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE)); } } /** * Resets the state of the destination store to its initial state. * @private */ reset_() { this.destinations_ = []; this.destinationMap_ = {}; this.selectDestination(null); this.loadedCloudOrigins_ = {}; for (const printerType of Object.values(print_preview.PrinterType)) { if (printerType !== print_preview.PrinterType.PDF_PRINTER) { this.destinationSearchStatus_.set( printerType, print_preview.DestinationStorePrinterSearchStatus.START); } } clearTimeout(this.autoSelectTimeout_); this.autoSelectTimeout_ = setTimeout( this.selectDefaultDestination_.bind(this), DestinationStore.AUTO_SELECT_TIMEOUT_); } /** * Called when destination search is complete for some type of printer. * @param {!print_preview.PrinterType} type The type of printers that are * done being retreived. */ onDestinationSearchDone_(type) { this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.DONE); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); if (type === print_preview.PrinterType.EXTENSION_PRINTER) this.endExtensionPrinterSearch_(); } /** * Called when the native layer retrieves the capabilities for the selected * local destination. Updates the destination with new capabilities if the * destination already exists, otherwise it creates a new destination and * then updates its capabilities. * @param {!print_preview.DestinationOrigin} origin The origin of the * print destination. * @param {string} id The id of the print destination. * @param {!print_preview.CapabilitiesResponse} settingsInfo Contains * the capabilities of the print destination, and information about * the destination except in the case of extension printers. * @private */ onCapabilitiesSet_(origin, id, settingsInfo) { let dest = null; if (origin !== print_preview.DestinationOrigin.PRIVET) { const key = this.getDestinationKey_(origin, id, ''); dest = this.destinationMap_[key]; } if (!dest) { // Ignore unrecognized extension printers if (!settingsInfo.printer) { assert(origin === print_preview.DestinationOrigin.EXTENSION); return; } dest = print_preview.parseDestination( print_preview.originToType(origin), assert(settingsInfo.printer)); } if (dest) { if ((origin === print_preview.DestinationOrigin.LOCAL || origin === print_preview.DestinationOrigin.CROS) && dest.capabilities) { // If capabilities are already set for this destination ignore new // results. This prevents custom margins from being cleared as long // as the user does not change to a new non-recent destination. return; } const updateDestination = destination => { destination.capabilities = settingsInfo.capabilities; this.updateDestination_(destination); }; if (Array.isArray(dest)) { dest.forEach(updateDestination); } else { updateDestination(dest); } } } /** * Called when a request to get a local destination's print capabilities * fails. If the destination is the initial destination, auto-select another * destination instead. * @param {print_preview.DestinationOrigin} origin The origin type of the * failed destination. * @param {string} destinationId The destination ID that failed. * @private */ onGetCapabilitiesFail_(origin, destinationId) { console.warn( 'Failed to get print capabilities for printer ' + destinationId); if (this.selectedDestination_ && this.selectedDestination_.id == destinationId) { const event = new Event(DestinationStore.EventType.SELECTED_DESTINATION_INVALID); event.destinationId = destinationId; this.dispatchEvent(event); } if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchIdAndOrigin( destinationId, origin)) { this.selectDefaultDestination_(); } } /** * Called when the /search call completes, either successfully or not. * In case of success, stores fetched destinations. * @param {Event} event Contains the request result. * @private */ onCloudPrintSearchDone_(event) { if (event.printers) { this.insertDestinations_(event.printers); } if (event.searchDone) { const origins = this.loadedCloudOrigins_[event.user] || []; if (origins.indexOf(event.origin) < 0) { this.loadedCloudOrigins_[event.user] = origins.concat([event.origin]); } } cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); } /** * Called when /printer call completes. Updates the specified destination's * print capabilities. * @param {Event} event Contains detailed information about the * destination. * @private */ onCloudPrintPrinterDone_(event) { this.updateDestination_(event.printer); } /** * Called when the Google Cloud Print interface fails to lookup a * destination. Selects another destination if the failed destination was * the initial destination. * @param {Object} event Contains the ID of the destination that was failed * to be looked up. * @private */ onCloudPrintPrinterFailed_(event) { if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchIdAndOrigin( event.destinationId, event.destinationOrigin)) { console.error( 'Failed to fetch last used printer caps: ' + event.destinationId); this.selectDefaultDestination_(); } } /** * Called when printer sharing invitation was processed successfully. * @param {Event} event Contains detailed information about the invite and * newly accepted destination (if known). * @private */ onCloudPrintProcessInviteDone_(event) { if (event.accept && event.printer) { // Hint the destination list to promote this new destination. event.printer.isRecent = true; this.insertDestination_(event.printer); } } /** * Called when a printer or printers are detected after sending getPrinters * from the native layer. * @param {print_preview.PrinterType} type The type of printer(s) added. * @param {!Array} printers * Information about the printers that have been retrieved. */ onPrintersAdded_(type, printers) { if (type == print_preview.PrinterType.PRIVET_PRINTER) { const printer = /** !print_preview.PrivetPrinterDescription */ (printers[0]); if (printer.serviceName == this.waitForRegisterDestination_ && !printer.isUnregistered) { this.waitForRegisterDestination_ = null; this.onDestinationsReload(); return; } } this.insertDestinations_(printers.map( printer => print_preview.parseDestination(type, printer))); } /** * Called from print preview after the user was requested to sign in, and * did so successfully. */ onDestinationsReload() { this.reset_(); this.autoSelectMatchingDestination_ = this.convertPreselectedToDestinationMatch_(); this.createLocalPdfPrintDestination_(); this.startLoadAllDestinations(); } // TODO(vitalybuka): Remove three next functions replacing Destination.id // and Destination.origin by complex ID. /** * Returns key to be used with {@code destinationMap_}. * @param {!print_preview.DestinationOrigin} origin Destination origin. * @param {string} id Destination id. * @param {string} account User account destination is registered for. * @private */ getDestinationKey_(origin, id, account) { return origin + '/' + id + '/' + account; } /** * Returns key to be used with {@code destinationMap_}. * @param {!print_preview.Destination} destination Destination. * @private */ getKey_(destination) { return this.getDestinationKey_( destination.origin, destination.id, destination.account); } } /** * Event types dispatched by the data store. * @enum {string} */ DestinationStore.EventType = { DESTINATION_SEARCH_DONE: 'print_preview.DestinationStore.DESTINATION_SEARCH_DONE', DESTINATION_SEARCH_STARTED: 'print_preview.DestinationStore.DESTINATION_SEARCH_STARTED', DESTINATION_SELECT: 'print_preview.DestinationStore.DESTINATION_SELECT', DESTINATIONS_INSERTED: 'print_preview.DestinationStore.DESTINATIONS_INSERTED', PROVISIONAL_DESTINATION_RESOLVED: 'print_preview.DestinationStore.PROVISIONAL_DESTINATION_RESOLVED', CACHED_SELECTED_DESTINATION_INFO_READY: 'print_preview.DestinationStore.CACHED_SELECTED_DESTINATION_INFO_READY', SELECTED_DESTINATION_CAPABILITIES_READY: 'print_preview.DestinationStore' + '.SELECTED_DESTINATION_CAPABILITIES_READY', SELECTED_DESTINATION_INVALID: 'print_preview.DestinationStore.SELECTED_DESTINATION_INVALID', }; /** * Delay in milliseconds before the destination store ignores the initial * destination ID and just selects any printer (since the initial destination * was not found). * @private {number} * @const */ DestinationStore.AUTO_SELECT_TIMEOUT_ = 15000; /** * Maximum amount of time spent searching for extension destinations, in * milliseconds. * @private {number} * @const */ DestinationStore.EXTENSION_SEARCH_DURATION_ = 5000; /** * Human readable names for media sizes in the cloud print CDD. * https://developers.google.com/cloud-print/docs/cdd * @private {Object} * @const */ DestinationStore.MEDIA_DISPLAY_NAMES_ = { 'ISO_2A0': '2A0', 'ISO_A0': 'A0', 'ISO_A0X3': 'A0x3', 'ISO_A1': 'A1', 'ISO_A10': 'A10', 'ISO_A1X3': 'A1x3', 'ISO_A1X4': 'A1x4', 'ISO_A2': 'A2', 'ISO_A2X3': 'A2x3', 'ISO_A2X4': 'A2x4', 'ISO_A2X5': 'A2x5', 'ISO_A3': 'A3', 'ISO_A3X3': 'A3x3', 'ISO_A3X4': 'A3x4', 'ISO_A3X5': 'A3x5', 'ISO_A3X6': 'A3x6', 'ISO_A3X7': 'A3x7', 'ISO_A3_EXTRA': 'A3 Extra', 'ISO_A4': 'A4', 'ISO_A4X3': 'A4x3', 'ISO_A4X4': 'A4x4', 'ISO_A4X5': 'A4x5', 'ISO_A4X6': 'A4x6', 'ISO_A4X7': 'A4x7', 'ISO_A4X8': 'A4x8', 'ISO_A4X9': 'A4x9', 'ISO_A4_EXTRA': 'A4 Extra', 'ISO_A4_TAB': 'A4 Tab', 'ISO_A5': 'A5', 'ISO_A5_EXTRA': 'A5 Extra', 'ISO_A6': 'A6', 'ISO_A7': 'A7', 'ISO_A8': 'A8', 'ISO_A9': 'A9', 'ISO_B0': 'B0', 'ISO_B1': 'B1', 'ISO_B10': 'B10', 'ISO_B2': 'B2', 'ISO_B3': 'B3', 'ISO_B4': 'B4', 'ISO_B5': 'B5', 'ISO_B5_EXTRA': 'B5 Extra', 'ISO_B6': 'B6', 'ISO_B6C4': 'B6C4', 'ISO_B7': 'B7', 'ISO_B8': 'B8', 'ISO_B9': 'B9', 'ISO_C0': 'C0', 'ISO_C1': 'C1', 'ISO_C10': 'C10', 'ISO_C2': 'C2', 'ISO_C3': 'C3', 'ISO_C4': 'C4', 'ISO_C5': 'C5', 'ISO_C6': 'C6', 'ISO_C6C5': 'C6C5', 'ISO_C7': 'C7', 'ISO_C7C6': 'C7C6', 'ISO_C8': 'C8', 'ISO_C9': 'C9', 'ISO_DL': 'Envelope DL', 'ISO_RA0': 'RA0', 'ISO_RA1': 'RA1', 'ISO_RA2': 'RA2', 'ISO_SRA0': 'SRA0', 'ISO_SRA1': 'SRA1', 'ISO_SRA2': 'SRA2', 'JIS_B0': 'B0 (JIS)', 'JIS_B1': 'B1 (JIS)', 'JIS_B10': 'B10 (JIS)', 'JIS_B2': 'B2 (JIS)', 'JIS_B3': 'B3 (JIS)', 'JIS_B4': 'B4 (JIS)', 'JIS_B5': 'B5 (JIS)', 'JIS_B6': 'B6 (JIS)', 'JIS_B7': 'B7 (JIS)', 'JIS_B8': 'B8 (JIS)', 'JIS_B9': 'B9 (JIS)', 'JIS_EXEC': 'Executive (JIS)', 'JPN_CHOU2': 'Choukei 2', 'JPN_CHOU3': 'Choukei 3', 'JPN_CHOU4': 'Choukei 4', 'JPN_HAGAKI': 'Hagaki', 'JPN_KAHU': 'Kahu Envelope', 'JPN_KAKU2': 'Kaku 2', 'JPN_OUFUKU': 'Oufuku Hagaki', 'JPN_YOU4': 'You 4', 'NA_10X11': '10x11', 'NA_10X13': '10x13', 'NA_10X14': '10x14', 'NA_10X15': '10x15', 'NA_11X12': '11x12', 'NA_11X15': '11x15', 'NA_12X19': '12x19', 'NA_5X7': '5x7', 'NA_6X9': '6x9', 'NA_7X9': '7x9', 'NA_9X11': '9x11', 'NA_A2': 'A2', 'NA_ARCH_A': 'Arch A', 'NA_ARCH_B': 'Arch B', 'NA_ARCH_C': 'Arch C', 'NA_ARCH_D': 'Arch D', 'NA_ARCH_E': 'Arch E', 'NA_ASME_F': 'ASME F', 'NA_B_PLUS': 'B-plus', 'NA_C': 'C', 'NA_C5': 'C5', 'NA_D': 'D', 'NA_E': 'E', 'NA_EDP': 'EDP', 'NA_EUR_EDP': 'European EDP', 'NA_EXECUTIVE': 'Executive', 'NA_F': 'F', 'NA_FANFOLD_EUR': 'FanFold European', 'NA_FANFOLD_US': 'FanFold US', 'NA_FOOLSCAP': 'FanFold German Legal', 'NA_GOVT_LEGAL': 'Government Legal', 'NA_GOVT_LETTER': 'Government Letter', 'NA_INDEX_3X5': 'Index 3x5', 'NA_INDEX_4X6': 'Index 4x6', 'NA_INDEX_4X6_EXT': 'Index 4x6 ext', 'NA_INDEX_5X8': '5x8', 'NA_INVOICE': 'Invoice', 'NA_LEDGER': 'Tabloid', // Ledger in portrait is called Tabloid. 'NA_LEGAL': 'Legal', 'NA_LEGAL_EXTRA': 'Legal extra', 'NA_LETTER': 'Letter', 'NA_LETTER_EXTRA': 'Letter extra', 'NA_LETTER_PLUS': 'Letter plus', 'NA_MONARCH': 'Monarch', 'NA_NUMBER_10': 'Envelope #10', 'NA_NUMBER_11': 'Envelope #11', 'NA_NUMBER_12': 'Envelope #12', 'NA_NUMBER_14': 'Envelope #14', 'NA_NUMBER_9': 'Envelope #9', 'NA_PERSONAL': 'Personal', 'NA_QUARTO': 'Quarto', 'NA_SUPER_A': 'Super A', 'NA_SUPER_B': 'Super B', 'NA_WIDE_FORMAT': 'Wide format', 'OM_DAI_PA_KAI': 'Dai-pa-kai', 'OM_FOLIO': 'Folio', 'OM_FOLIO_SP': 'Folio SP', 'OM_INVITE': 'Invite Envelope', 'OM_ITALIAN': 'Italian Envelope', 'OM_JUURO_KU_KAI': 'Juuro-ku-kai', 'OM_LARGE_PHOTO': 'Large photo', 'OM_OFICIO': 'Oficio', 'OM_PA_KAI': 'Pa-kai', 'OM_POSTFIX': 'Postfix Envelope', 'OM_SMALL_PHOTO': 'Small photo', 'PRC_1': 'prc1 Envelope', 'PRC_10': 'prc10 Envelope', 'PRC_16K': 'prc 16k', 'PRC_2': 'prc2 Envelope', 'PRC_3': 'prc3 Envelope', 'PRC_32K': 'prc 32k', 'PRC_4': 'prc4 Envelope', 'PRC_5': 'prc5 Envelope', 'PRC_6': 'prc6 Envelope', 'PRC_7': 'prc7 Envelope', 'PRC_8': 'prc8 Envelope', 'ROC_16K': 'ROC 16K', 'ROC_8K': 'ROC 8k', }; // Export return {DestinationStore: DestinationStore}; }); // // 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('print_preview', function() { 'use strict'; class Invitation { /** * Printer sharing invitation data object. * @param {string} sender Text identifying invitation sender. * @param {string} receiver Text identifying invitation receiver. Empty in * case of a personal invitation. Identifies a group or domain in case * of an invitation received by a group manager. * @param {!print_preview.Destination} destination Shared destination. * @param {!Object} aclEntry JSON representation of the ACL entry this * invitation was sent to. * @param {string} account User account this invitation is sent for. */ constructor(sender, receiver, destination, aclEntry, account) { /** * Text identifying invitation sender. * @private {string} */ this.sender_ = sender; /** * Text identifying invitation receiver. Empty in case of a personal * invitation. Identifies a group or domain in case of an invitation * received by a group manager. * @private {string} */ this.receiver_ = receiver; /** * Shared destination. * @private {!print_preview.Destination} */ this.destination_ = destination; /** * JSON representation of the ACL entry this invitation was sent to. * @private {!Object} */ this.aclEntry_ = aclEntry; /** * Account this invitation is sent for. * @private {string} */ this.account_ = account; } /** @return {string} Text identifying invitation sender. */ get sender() { return this.sender_; } /** @return {string} Text identifying invitation receiver. */ get receiver() { return this.receiver_; } /** * @return {boolean} Whether this user acts as a manager for a group of * users. */ get asGroupManager() { return !!this.receiver_; } /** @return {!print_preview.Destination} Shared destination. */ get destination() { return this.destination_; } /** @return {string} Scope (account) this invitation was sent to. */ get scopeId() { return this.aclEntry_['scope'] || ''; } /** @return {string} Account this invitation is sent for. */ get account() { return this.account_; } } // Export return {Invitation: Invitation}; }); // // 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.exportPath('print_preview'); /** * @enum {number} * @private */ print_preview.InvitationStoreLoadStatus = { IN_PROGRESS: 1, DONE: 2, FAILED: 3 }; cr.define('print_preview', function() { 'use strict'; class InvitationStore extends cr.EventTarget { /** * Printer sharing invitations data store. * @param {!print_preview.UserInfo} userInfo User information repository. */ constructor(userInfo) { super(); /** * User information repository. * @private {!print_preview.UserInfo} */ this.userInfo_ = userInfo; /** * Maps user account to the list of invitations for this account. * @private {!Object>} */ this.invitations_ = {}; /** * Maps user account to the flag whether the invitations for this account * were successfully loaded. * @private {!Object} */ this.loadStatus_ = {}; /** * Event tracker used to track event listeners of the destination store. * @private {!EventTracker} */ this.tracker_ = new EventTracker(); /** * Used to fetch and process invitations. * @private {cloudprint.CloudPrintInterface} */ this.cloudPrintInterface_ = null; /** * Invitation being processed now. Only one invitation can be processed at * a time. * @private {print_preview.Invitation} */ this.invitationInProgress_ = null; } /** * @return {print_preview.Invitation} Currently processed invitation or * {@code null}. */ get invitationInProgress() { return this.invitationInProgress_; } /** * @param {string} account Account to filter invitations by. * @return {!Array} List of invitations for the * {@code account}. */ invitations(account) { return this.invitations_[account] || []; } /** * Sets the invitation store's Google Cloud Print interface. * @param {!cloudprint.CloudPrintInterface} cloudPrintInterface Interface * to set. */ setCloudPrintInterface(cloudPrintInterface) { assert(this.cloudPrintInterface_ == null); this.cloudPrintInterface_ = cloudPrintInterface; this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.INVITES_DONE, this.onCloudPrintInvitesDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.INVITES_FAILED, this.onCloudPrintInvitesDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PROCESS_INVITE_DONE, this.onCloudPrintProcessInviteDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PROCESS_INVITE_FAILED, this.onCloudPrintProcessInviteFailed_.bind(this)); } /** Initiates loading of cloud printer sharing invitations. */ startLoadingInvitations() { if (!this.cloudPrintInterface_) return; if (!this.userInfo_.activeUser) return; if (this.loadStatus_.hasOwnProperty(this.userInfo_.activeUser)) { if (this.loadStatus_[this.userInfo_.activeUser] == print_preview.InvitationStoreLoadStatus.DONE) { cr.dispatchSimpleEvent( this, InvitationStore.EventType.INVITATION_SEARCH_DONE); } return; } this.loadStatus_[this.userInfo_.activeUser] = print_preview.InvitationStoreLoadStatus.IN_PROGRESS; this.cloudPrintInterface_.invites(this.userInfo_.activeUser); } /** * Accepts or rejects the {@code invitation}, based on {@code accept} value. * @param {!print_preview.Invitation} invitation Invitation to process. * @param {boolean} accept Whether to accept this invitation. */ processInvitation(invitation, accept) { if (this.invitationInProgress_) return; this.invitationInProgress_ = invitation; this.cloudPrintInterface_.processInvite(invitation, accept); } /** * Removes processed invitation from the internal storage. * @param {!print_preview.Invitation} invitation Processed invitation. * @private */ invitationProcessed_(invitation) { if (this.invitations_.hasOwnProperty(invitation.account)) { this.invitations_[invitation.account] = this.invitations_[invitation.account].filter(function(i) { return i != invitation; }); } if (this.invitationInProgress_ == invitation) this.invitationInProgress_ = null; } /** * Called when printer sharing invitations are fetched. * @param {Event} event Contains the list of invitations. * @private */ onCloudPrintInvitesDone_(event) { this.loadStatus_[event.user] = print_preview.InvitationStoreLoadStatus.DONE; this.invitations_[event.user] = event.invitations; cr.dispatchSimpleEvent( this, InvitationStore.EventType.INVITATION_SEARCH_DONE); } /** * Called when printer sharing invitations fetch has failed. * @param {Event} event Contains the reason of failure. * @private */ onCloudPrintInvitesFailed_(event) { this.loadStatus_[event.user] = print_preview.InvitationStoreLoadStatus.FAILED; } /** * Called when printer sharing invitation was processed successfully. * @param {Event} event Contains detailed information about the invite and * newly accepted destination. * @private */ onCloudPrintProcessInviteDone_(event) { this.invitationProcessed_(event.invitation); cr.dispatchSimpleEvent( this, InvitationStore.EventType.INVITATION_PROCESSED); } /** * Called when /printer call completes. Updates the specified destination's * print capabilities. * @param {Event} event Contains detailed information about the * destination. * @private */ onCloudPrintProcessInviteFailed_(event) { this.invitationProcessed_(event.invitation); // TODO: Display an error. cr.dispatchSimpleEvent( this, InvitationStore.EventType.INVITATION_PROCESSED); } } /** * Event types dispatched by the data store. * @enum {string} */ InvitationStore.EventType = { INVITATION_PROCESSED: 'print_preview.InvitationStore.INVITATION_PROCESSED', INVITATION_SEARCH_DONE: 'print_preview.InvitationStore.INVITATION_SEARCH_DONE' }; // Export return {InvitationStore: InvitationStore}; }); // // 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.exportPath('print_preview.ticket_items'); /** * Enumeration of the orientations of margins. * @enum {string} */ print_preview.ticket_items.CustomMarginsOrientation = { TOP: 'top', RIGHT: 'right', BOTTOM: 'bottom', LEFT: 'left' }; cr.define('print_preview', function() { 'use strict'; class Margins { /** * Creates a Margins object that holds four margin values in points. * @param {number} top The top margin in pts. * @param {number} right The right margin in pts. * @param {number} bottom The bottom margin in pts. * @param {number} left The left margin in pts. */ constructor(top, right, bottom, left) { /** * Backing store for the margin values in points. * @type {!Object< * !print_preview.ticket_items.CustomMarginsOrientation, number>} * @private */ this.value_ = {}; this.value_[print_preview.ticket_items.CustomMarginsOrientation.TOP] = top; this.value_[print_preview.ticket_items.CustomMarginsOrientation.RIGHT] = right; this.value_[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM] = bottom; this.value_[print_preview.ticket_items.CustomMarginsOrientation.LEFT] = left; } /** * Parses a margins object from the given serialized state. * @param {Object} state Serialized representation of the margins created by * the {@code serialize} method. * @return {!print_preview.Margins} New margins instance. */ static parse(state) { return new print_preview.Margins( state[print_preview.ticket_items.CustomMarginsOrientation.TOP] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.RIGHT] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.LEFT] || 0); } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin value to get. * @return {number} Value of the margin of the given orientation. */ get(orientation) { return this.value_[orientation]; } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin to set. * @param {number} value Updated value of the margin in points to modify. * @return {!print_preview.Margins} A new copy of |this| with the * modification made to the specified margin. */ set(orientation, value) { const newValue = this.clone_(); newValue[orientation] = value; return new Margins( newValue[print_preview.ticket_items.CustomMarginsOrientation.TOP], newValue[print_preview.ticket_items.CustomMarginsOrientation.RIGHT], newValue[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM], newValue[print_preview.ticket_items.CustomMarginsOrientation.LEFT]); } /** * @param {print_preview.Margins} other The other margins object to compare * against. * @return {boolean} Whether this margins object is equal to another. */ equals(other) { if (other == null) { return false; } for (const orientation in this.value_) { if (this.value_[orientation] != other.value_[orientation]) { return false; } } return true; } /** @return {Object} A serialized representation of the margins. */ serialize() { return this.clone_(); } /** * @return {Object} Cloned state of the margins. * @private */ clone_() { const clone = {}; for (const o in this.value_) { clone[o] = this.value_[o]; } return clone; } } // Export return {Margins: Margins}; }); // // 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('print_preview', function() { 'use strict'; class DocumentInfo extends cr.EventTarget { /** * Data model which contains information related to the document to print. */ constructor() { super(); /** * Whether the document is styled by CSS media styles. * @private {boolean} */ this.hasCssMediaStyles_ = false; /** * Whether the document has selected content. * @private {boolean} */ this.hasSelection_ = false; /** * Whether the document to print is modifiable (i.e. can be reflowed). * @private {boolean} */ this.isModifiable_ = true; /** * Whether scaling of the document is prohibited. * @private {boolean} */ this.isScalingDisabled_ = false; /** * Scaling required to fit to page. * @private {number} */ this.fitToPageScaling_ = 100; /** * Margins of the document in points. * @private {print_preview.Margins} */ this.margins_ = null; /** * Number of pages in the document to print. * @private {number} */ this.pageCount_ = 0; /** * Size of the pages of the document in points. Actual page-related * information won't be set until preview generation occurs, so use * a default value until then. This way, the print ticket store will be * valid even if no preview can be generated. * @private {!print_preview.Size} */ this.pageSize_ = new print_preview.Size(612, 792); // 8.5"x11" /** * Printable area of the document in points. * @private {!print_preview.PrintableArea} */ this.printableArea_ = new print_preview.PrintableArea( new print_preview.Coordinate2d(0, 0), this.pageSize_); /** * Title of document. * @private {string} */ this.title_ = ''; /** * Whether this data model has been initialized. * @private {boolean} */ this.isInitialized_ = false; } /** @return {boolean} Whether the document is styled by CSS media styles. */ get hasCssMediaStyles() { return this.hasCssMediaStyles_; } /** @return {boolean} Whether the document has selected content. */ get hasSelection() { return this.hasSelection_; } /** * @return {boolean} Whether the document to print is modifiable (i.e. can * be reflowed). */ get isModifiable() { return this.isModifiable_; } /** @return {boolean} Whether scaling of the document is prohibited. */ get isScalingDisabled() { return this.isScalingDisabled_; } /** @return {number} Scaling required to fit to page. */ get fitToPageScaling() { return this.fitToPageScaling_; } /** @return {print_preview.Margins} Margins of the document in points. */ get margins() { return this.margins_; } /** @return {number} Number of pages in the document to print. */ get pageCount() { return this.pageCount_; } /** * @return {!print_preview.Size} Size of the pages of the document in * points. */ get pageSize() { return this.pageSize_; } /** * @return {!print_preview.PrintableArea} Printable area of the document in * points. */ get printableArea() { return this.printableArea_; } /** @return {string} Title of document. */ get title() { return this.title_; } /** * Initializes the state of the data model and dispatches a CHANGE event. * @param {boolean} isModifiable Whether the document is modifiable. * @param {string} title Title of the document. * @param {boolean} hasSelection Whether the document has user-selected * content. */ init(isModifiable, title, hasSelection) { this.isModifiable_ = isModifiable; this.title_ = title; this.hasSelection_ = hasSelection; this.isInitialized_ = true; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } /** * Updates whether scaling is disabled for the document and dispatches a * CHANGE event. * @param {boolean} isScalingDisabled Whether scaling of the document is * prohibited. */ updateIsScalingDisabled(isScalingDisabled) { if (this.isInitialized_ && this.isScalingDisabled_ != isScalingDisabled) { this.isScalingDisabled_ = isScalingDisabled; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } /** * Updates the total number of pages in the document and dispatches a CHANGE * event. * @param {number} pageCount Number of pages in the document. */ updatePageCount(pageCount) { if (this.isInitialized_ && this.pageCount_ != pageCount) { this.pageCount_ = pageCount; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } /** * Updates information about each page and dispatches a CHANGE event. * @param {!print_preview.PrintableArea} printableArea Printable area of the * document in points. * @param {!print_preview.Size} pageSize Size of the pages of the document * in points. * @param {boolean} hasCssMediaStyles Whether the document is styled by CSS * media styles. * @param {print_preview.Margins} margins Margins of the document in points. */ updatePageInfo(printableArea, pageSize, hasCssMediaStyles, margins) { if (this.isInitialized_ && (!this.printableArea_.equals(printableArea) || !this.pageSize_.equals(pageSize) || this.hasCssMediaStyles_ != hasCssMediaStyles || this.margins_ == null || !this.margins_.equals(margins))) { this.printableArea_ = printableArea; this.pageSize_ = pageSize; this.hasCssMediaStyles_ = hasCssMediaStyles; this.margins_ = margins; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } } /** * Event types dispatched by this data model. * @enum {string} */ DocumentInfo.EventType = {CHANGE: 'print_preview.DocumentInfo.CHANGE'}; // Export return {DocumentInfo: DocumentInfo}; }); // // 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('print_preview', function() { 'use strict'; class PrintableArea { /** * Object describing the printable area of a page in the document. * @param {!print_preview.Coordinate2d} origin Top left corner of the * printable area of the document. * @param {!print_preview.Size} size Size of the printable area of the * document. */ constructor(origin, size) { /** * Top left corner of the printable area of the document. * @type {!print_preview.Coordinate2d} * @private */ this.origin_ = origin; /** * Size of the printable area of the document. * @type {!print_preview.Size} * @private */ this.size_ = size; } /** * @return {!print_preview.Coordinate2d} Top left corner of the printable * area of the document. */ get origin() { return this.origin_; } /** * @return {!print_preview.Size} Size of the printable area of the document. */ get size() { return this.size_; } /** * @param {print_preview.PrintableArea} other Other printable area to check * for equality. * @return {boolean} Whether another printable area is equal to this one. */ equals(other) { return other != null && this.origin_.equals(other.origin_) && this.size_.equals(other.size_); } } // Export return {PrintableArea: PrintableArea}; }); // // 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.exportPath('print_preview'); /** * Enumeration of measurement unit types. * @enum {number} */ print_preview.MeasurementSystemUnitType = { METRIC: 0, // millimeters IMPERIAL: 1 // inches }; /** * @typedef {{precision: number, * decimalPlaces: number, * ptsPerUnit: number, * unitSymbol: string}} */ print_preview.MeasurementSystemPrefs; cr.define('print_preview', function() { 'use strict'; class MeasurementSystem { /** * Measurement system of the print preview. Used to parse and serialize * point measurements into the system's local units (e.g. millimeters, * inches). * @param {string} thousandsDelimeter Delimeter between thousands digits. * @param {string} decimalDelimeter Delimeter between integers and decimals. * @param {!print_preview.MeasurementSystemUnitType} unitType Measurement * unit type of the system. */ constructor(thousandsDelimeter, decimalDelimeter, unitType) { /** * The thousands delimeter to use when displaying numbers. * @private {string} */ this.thousandsDelimeter_ = thousandsDelimeter || ','; /** * The decimal delimeter to use when displaying numbers. * @private {string} */ this.decimalDelimeter_ = decimalDelimeter || '.'; assert(measurementSystemPrefs.has(unitType)); /** * The measurement system preferences based on the unit type. * @private {!print_preview.MeasurementSystemPrefs} */ this.measurementSystemPrefs_ = measurementSystemPrefs.get(unitType); } /** @return {string} The unit type symbol of the measurement system. */ get unitSymbol() { return this.measurementSystemPrefs_.unitSymbol; } /** * @return {string} The thousands delimeter character of the measurement * system. */ get thousandsDelimeter() { return this.thousandsDelimeter_; } /** * @return {string} The decimal delimeter character of the measurement * system. */ get decimalDelimeter() { return this.decimalDelimeter_; } /** * Sets the measurement system based on the delimeters and unit type. * @param {string} thousandsDelimeter The thousands delimeter to use * @param {string} decimalDelimeter The decimal delimeter to use * @param {!print_preview.MeasurementSystemUnitType} unitType Measurement * unit type of the system. */ setSystem(thousandsDelimeter, decimalDelimeter, unitType) { this.thousandsDelimeter_ = thousandsDelimeter; this.decimalDelimeter_ = decimalDelimeter; assert(measurementSystemPrefs.has(unitType)); this.measurementSystemPrefs_ = measurementSystemPrefs.get(unitType); } /** * Rounds a value in the local system's units to the appropriate precision. * @param {number} value Value to round. * @return {number} Rounded value. */ roundValue(value) { const precision = this.measurementSystemPrefs_.precision; const roundedValue = Math.round(value / precision) * precision; // Truncate return +roundedValue.toFixed(this.measurementSystemPrefs_.decimalPlaces); } /** * @param {number} pts Value in points to convert to local units. * @return {number} Value in local units. */ convertFromPoints(pts) { return pts / this.measurementSystemPrefs_.ptsPerUnit; } /** * @param {number} localUnits Value in local units to convert to points. * @return {number} Value in points. */ convertToPoints(localUnits) { return localUnits * this.measurementSystemPrefs_.ptsPerUnit; } } /** * Maximum resolution and number of decimal places for local unit values. * @private {!Map} */ const measurementSystemPrefs = new Map([ [ print_preview.MeasurementSystemUnitType.METRIC, { precision: 0.5, decimalPlaces: 1, ptsPerUnit: 72.0 / 25.4, unitSymbol: 'mm' } ], [ print_preview.MeasurementSystemUnitType.IMPERIAL, {precision: 0.01, decimalPlaces: 2, ptsPerUnit: 72.0, unitSymbol: '"'} ] ]); // Export return {MeasurementSystem: MeasurementSystem}; }); // // 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('print_preview', function() { 'use strict'; // TODO(rltoscano): Maybe clear print ticket when destination changes. Or // better yet, carry over any print ticket state that is possible. I.e. if // destination changes, the new destination might not support duplex anymore, // so we should clear the ticket's isDuplexEnabled state. class PrintTicketStore extends cr.EventTarget { /** * Storage of the print ticket and document statistics. Dispatches events * when the contents of the print ticket or document statistics change. Also * handles validation of the print ticket against destination capabilities * and against the document. * @param {!print_preview.DestinationStore} destinationStore Used to * understand which printer is selected. * @param {!print_preview.AppState} appState Print preview application * state. * @param {!print_preview.DocumentInfo} documentInfo Document data model. */ constructor(destinationStore, appState, documentInfo) { super(); /** * Destination store used to understand which printer is selected. * @type {!print_preview.DestinationStore} * @private */ this.destinationStore_ = destinationStore; /** * App state used to persist and load ticket values. * @type {!print_preview.AppState} * @private */ this.appState_ = appState; /** * Information about the document to print. * @type {!print_preview.DocumentInfo} * @private */ this.documentInfo_ = documentInfo; /** * The destination that capabilities were last received for. * @private {?print_preview.Destination} */ this.destination_ = null; /** * Printing capabilities of Chromium and the currently selected * destination. * @type {!print_preview.CapabilitiesHolder} * @private */ this.capabilitiesHolder_ = new print_preview.CapabilitiesHolder(); /** * Current measurement system. Used to work with margin measurements. * @type {!print_preview.MeasurementSystem} * @private */ this.measurementSystem_ = new print_preview.MeasurementSystem( ',', '.', print_preview.MeasurementSystemUnitType.IMPERIAL); /** * Collate ticket item. * @type {!print_preview.ticket_items.Collate} * @private */ this.collate_ = new print_preview.ticket_items.Collate( this.appState_, this.destinationStore_); /** * Color ticket item. * @type {!print_preview.ticket_items.Color} * @private */ this.color_ = new print_preview.ticket_items.Color( this.appState_, this.destinationStore_); /** * Copies ticket item. * @type {!print_preview.ticket_items.Copies} * @private */ this.copies_ = new print_preview.ticket_items.Copies(this.destinationStore_); /** * DPI ticket item. * @type {!print_preview.ticket_items.Dpi} * @private */ this.dpi_ = new print_preview.ticket_items.Dpi( this.appState_, this.destinationStore_); /** * Duplex ticket item. * @type {!print_preview.ticket_items.Duplex} * @private */ this.duplex_ = new print_preview.ticket_items.Duplex( this.appState_, this.destinationStore_); /** * Page range ticket item. * @type {!print_preview.ticket_items.PageRange} * @private */ this.pageRange_ = new print_preview.ticket_items.PageRange(this.documentInfo_); /** * Rasterize PDF ticket item. * @type {!print_preview.ticket_items.Rasterize} * @private */ this.rasterize_ = new print_preview.ticket_items.Rasterize( this.destinationStore_, this.documentInfo_); /** * Scaling ticket item. * @type {!print_preview.ticket_items.Scaling} * @private */ this.scaling_ = new print_preview.ticket_items.Scaling( this.appState_, this.destinationStore_, this.documentInfo_); /** * Custom margins ticket item. * @type {!print_preview.ticket_items.CustomMargins} * @private */ this.customMargins_ = new print_preview.ticket_items.CustomMargins( this.appState_, this.documentInfo_); /** * Margins type ticket item. * @type {!print_preview.ticket_items.MarginsType} * @private */ this.marginsType_ = new print_preview.ticket_items.MarginsType( this.appState_, this.documentInfo_, this.customMargins_); /** * Media size ticket item. * @type {!print_preview.ticket_items.MediaSize} * @private */ this.mediaSize_ = new print_preview.ticket_items.MediaSize( this.appState_, this.destinationStore_, this.documentInfo_, this.marginsType_, this.customMargins_); /** * Landscape ticket item. * @type {!print_preview.ticket_items.Landscape} * @private */ this.landscape_ = new print_preview.ticket_items.Landscape( this.appState_, this.destinationStore_, this.documentInfo_, this.marginsType_, this.customMargins_); /** * Header-footer ticket item. * @type {!print_preview.ticket_items.HeaderFooter} * @private */ this.headerFooter_ = new print_preview.ticket_items.HeaderFooter( this.appState_, this.documentInfo_, this.marginsType_, this.customMargins_, this.mediaSize_, this.landscape_); /** * Fit-to-page ticket item. * @type {!print_preview.ticket_items.FitToPage} * @private */ this.fitToPage_ = new print_preview.ticket_items.FitToPage( this.appState_, this.documentInfo_, this.destinationStore_); /** * Print CSS backgrounds ticket item. * @type {!print_preview.ticket_items.CssBackground} * @private */ this.cssBackground_ = new print_preview.ticket_items.CssBackground( this.appState_, this.documentInfo_); /** * Print selection only ticket item. * @type {!print_preview.ticket_items.SelectionOnly} * @private */ this.selectionOnly_ = new print_preview.ticket_items.SelectionOnly(this.documentInfo_); /** * Vendor ticket items. * @type {!print_preview.ticket_items.VendorItems} * @private */ this.vendorItems_ = new print_preview.ticket_items.VendorItems( this.appState_, this.destinationStore_); /** * Keeps track of event listeners for the print ticket store. * @type {!EventTracker} * @private */ this.tracker_ = new EventTracker(); /** * Whether the print preview has been initialized. * @type {boolean} * @private */ this.isInitialized_ = false; this.addEventListeners_(); } get isInitialized() { return this.isInitialized_; } get collate() { return this.collate_; } get color() { return this.color_; } get copies() { return this.copies_; } get cssBackground() { return this.cssBackground_; } get customMargins() { return this.customMargins_; } get dpi() { return this.dpi_; } get duplex() { return this.duplex_; } get fitToPage() { return this.fitToPage_; } get headerFooter() { return this.headerFooter_; } get mediaSize() { return this.mediaSize_; } get landscape() { return this.landscape_; } get marginsType() { return this.marginsType_; } get pageRange() { return this.pageRange_; } get rasterize() { return this.rasterize_; } get scaling() { return this.scaling_; } get selectionOnly() { return this.selectionOnly_; } get vendorItems() { return this.vendorItems_; } get measurementSystem() { return this.measurementSystem_; } /** * Initializes the print ticket store. Dispatches an INITIALIZE event. * @param {string} thousandsDelimeter Delimeter of the thousands place. * @param {string} decimalDelimeter Delimeter of the decimal point. * @param {!print_preview.MeasurementSystemUnitType} unitType Type of unit * of the local measurement system. * @param {boolean} selectionOnly Whether only selected content should be * printed. */ init(thousandsDelimeter, decimalDelimeter, unitType, selectionOnly) { this.measurementSystem_.setSystem( thousandsDelimeter, decimalDelimeter, unitType); this.selectionOnly_.updateValue(selectionOnly); // Initialize ticket with user's previous values. if (this.appState_.hasField( print_preview.AppStateField.IS_COLOR_ENABLED)) { this.color_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_COLOR_ENABLED))); } if (this.appState_.hasField(print_preview.AppStateField.DPI)) { this.dpi_.updateValue( /** @type {!Object} */ ( this.appState_.getField(print_preview.AppStateField.DPI))); } if (this.appState_.hasField( print_preview.AppStateField.IS_DUPLEX_ENABLED)) { this.duplex_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_DUPLEX_ENABLED))); } if (this.appState_.hasField(print_preview.AppStateField.MEDIA_SIZE)) { this.mediaSize_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.MEDIA_SIZE))); } if (this.appState_.hasField( print_preview.AppStateField.IS_LANDSCAPE_ENABLED)) { this.landscape_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_LANDSCAPE_ENABLED))); } // Initialize margins after landscape because landscape may reset margins. if (this.appState_.hasField(print_preview.AppStateField.MARGINS_TYPE)) { this.marginsType_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.MARGINS_TYPE))); } if (this.appState_.hasField(print_preview.AppStateField.CUSTOM_MARGINS)) { this.customMargins_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.CUSTOM_MARGINS))); } if (this.appState_.hasField( print_preview.AppStateField.IS_HEADER_FOOTER_ENABLED)) { this.headerFooter_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_HEADER_FOOTER_ENABLED))); } if (this.appState_.hasField( print_preview.AppStateField.IS_COLLATE_ENABLED)) { this.collate_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_COLLATE_ENABLED))); } if (this.appState_.hasField( print_preview.AppStateField.IS_FIT_TO_PAGE_ENABLED)) { this.fitToPage_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_FIT_TO_PAGE_ENABLED))); } if (this.appState_.hasField(print_preview.AppStateField.SCALING)) { this.scaling_.updateValue( /** @type {!Object} */ ( this.appState_.getField(print_preview.AppStateField.SCALING))); } if (this.appState_.hasField( print_preview.AppStateField.IS_CSS_BACKGROUND_ENABLED)) { this.cssBackground_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.IS_CSS_BACKGROUND_ENABLED))); } if (this.appState_.hasField(print_preview.AppStateField.VENDOR_OPTIONS)) { this.vendorItems_.updateValue( /** @type {!Object} */ (this.appState_.getField( print_preview.AppStateField.VENDOR_OPTIONS))); } } /** * @return {boolean} {@code true} if the stored print ticket is valid, * {@code false} otherwise. */ isTicketValid() { return this.isTicketValidForPreview() && (!this.copies_.isCapabilityAvailable() || this.copies_.isValid()) && (!this.pageRange_.isCapabilityAvailable() || this.pageRange_.isValid()); } /** @return {boolean} Whether the ticket is valid for preview generation. */ isTicketValidForPreview() { return ( (!this.marginsType_.isCapabilityAvailable() || !this.marginsType_.isValueEqual( print_preview.ticket_items.MarginsTypeValue.CUSTOM) || this.customMargins_.isValid()) && (!this.scaling_.isCapabilityAvailable() || this.scaling_.isValid())); } /** * Creates an object that represents a Google Cloud Print print ticket. * @param {!print_preview.Destination} destination Destination to print to. * @return {string} Google Cloud Print print ticket. */ createPrintTicket(destination) { assert( !destination.isLocal || destination.isPrivet || destination.isExtension, 'Trying to create a Google Cloud Print print ticket for a local ' + ' non-privet and non-extension destination'); assert( destination.capabilities, 'Trying to create a Google Cloud Print print ticket for a ' + 'destination with no print capabilities'); const cjt = {version: '1.0', print: {}}; if (this.collate.isCapabilityAvailable() && this.collate.isUserEdited()) { cjt.print.collate = {collate: this.collate.getValue()}; } if (this.color.isCapabilityAvailable() && this.color.isUserEdited()) { const selectedOption = destination.getSelectedColorOption(this.color.getValue()); if (!selectedOption) { console.error('Could not find correct color option'); } else { cjt.print.color = {type: selectedOption.type}; if (selectedOption.hasOwnProperty('vendor_id')) { cjt.print.color.vendor_id = selectedOption.vendor_id; } } } else { // Always try setting the color in the print ticket, otherwise a // reasonable reader of the ticket will have to do more work, or process // the ticket sub-optimally, in order to safely handle the lack of a // color ticket item. const defaultOption = destination.defaultColorOption; if (defaultOption) { cjt.print.color = {type: defaultOption.type}; if (defaultOption.hasOwnProperty('vendor_id')) { cjt.print.color.vendor_id = defaultOption.vendor_id; } } } if (this.copies.isCapabilityAvailable() && this.copies.isUserEdited()) { cjt.print.copies = {copies: this.copies.getValueAsNumber()}; } if (this.duplex.isCapabilityAvailable() && this.duplex.isUserEdited()) { cjt.print.duplex = { type: this.duplex.getValue() ? 'LONG_EDGE' : 'NO_DUPLEX' }; } if (this.mediaSize.isCapabilityAvailable()) { const mediaValue = this.mediaSize.getValue(); cjt.print.media_size = { width_microns: mediaValue.width_microns, height_microns: mediaValue.height_microns, is_continuous_feed: mediaValue.is_continuous_feed, vendor_id: mediaValue.vendor_id }; } if (!this.landscape.isCapabilityAvailable()) { // In this case "orientation" option is hidden from user, so user can't // adjust it for page content, see Landscape.isCapabilityAvailable(). // We can improve results if we set AUTO here. if (this.landscape.hasOption('AUTO')) cjt.print.page_orientation = {type: 'AUTO'}; } else if (this.landscape.isUserEdited()) { cjt.print.page_orientation = { type: this.landscape.getValue() ? 'LANDSCAPE' : 'PORTRAIT' }; } if (this.dpi.isCapabilityAvailable()) { const dpiValue = this.dpi.getValue(); cjt.print.dpi = { horizontal_dpi: dpiValue.horizontal_dpi, vertical_dpi: dpiValue.vertical_dpi, vendor_id: dpiValue.vendor_id }; } if (this.vendorItems.isCapabilityAvailable() && this.vendorItems.isUserEdited()) { const items = this.vendorItems.ticketItems; cjt.print.vendor_ticket_item = []; for (const itemId in items) { if (items.hasOwnProperty(itemId)) { cjt.print.vendor_ticket_item.push( {id: itemId, value: items[itemId]}); } } } return JSON.stringify(cjt); } /** * Adds event listeners for the print ticket store. * @private */ addEventListeners_() { this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType .SELECTED_DESTINATION_CAPABILITIES_READY, this.onSelectedDestinationCapabilitiesReady_.bind(this)); this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType .CACHED_SELECTED_DESTINATION_INFO_READY, this.onSelectedDestinationCapabilitiesReady_.bind(this)); // TODO(rltoscano): Print ticket store shouldn't be re-dispatching these // events, the consumers of the print ticket store events should listen // for the events from document info instead. Will move this when // consumers are all migrated. this.tracker_.add( this.documentInfo_, print_preview.DocumentInfo.EventType.CHANGE, this.onDocumentInfoChange_.bind(this)); } /** * Called when the capabilities of the selected destination are ready. * @private */ onSelectedDestinationCapabilitiesReady_() { const selectedDestination = this.destinationStore_.selectedDestination; const isFirstUpdate = this.capabilitiesHolder_.get() == null; // Only clear the ticket items if the user selected a new destination // and this is not the first update. if (!isFirstUpdate && this.destination_ != selectedDestination) { this.customMargins_.updateValue(null); if (this.marginsType_.getValue() == print_preview.ticket_items.MarginsTypeValue.CUSTOM) { this.marginsType_.updateValue( print_preview.ticket_items.MarginsTypeValue.DEFAULT); } this.vendorItems_.updateValue({}); } const caps = assert(selectedDestination.capabilities); this.capabilitiesHolder_.set(caps); this.destination_ = selectedDestination; if (isFirstUpdate) { this.isInitialized_ = true; cr.dispatchSimpleEvent(this, PrintTicketStore.EventType.INITIALIZE); } else { cr.dispatchSimpleEvent( this, PrintTicketStore.EventType.CAPABILITIES_CHANGE); } } /** * Called when document data model has changed. Dispatches a print ticket * store event. * @private */ onDocumentInfoChange_() { cr.dispatchSimpleEvent(this, PrintTicketStore.EventType.DOCUMENT_CHANGE); } } /** * Event types dispatched by the print ticket store. * @enum {string} */ PrintTicketStore.EventType = { CAPABILITIES_CHANGE: 'print_preview.PrintTicketStore.CAPABILITIES_CHANGE', DOCUMENT_CHANGE: 'print_preview.PrintTicketStore.DOCUMENT_CHANGE', INITIALIZE: 'print_preview.PrintTicketStore.INITIALIZE', }; // Export return {PrintTicketStore: PrintTicketStore}; }); // // 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('print_preview', function() { 'use strict'; class Coordinate2d { /** * Immutable two dimensional point in space. The units of the dimensions are * undefined. * @param {number} x X-dimension of the point. * @param {number} y Y-dimension of the point. */ constructor(x, y) { /** * X-dimension of the point. * @type {number} * @private */ this.x_ = x; /** * Y-dimension of the point. * @type {number} * @private */ this.y_ = y; } /** @return {number} X-dimension of the point. */ get x() { return this.x_; } /** @return {number} Y-dimension of the point. */ get y() { return this.y_; } /** * @param {number} x Amount to translate in the X dimension. * @param {number} y Amount to translate in the Y dimension. * @return {!print_preview.Coordinate2d} A new two-dimensional point * translated along the X and Y dimensions. */ translate(x, y) { return new Coordinate2d(this.x_ + x, this.y_ + y); } /** * @param {number} factor Amount to scale the X and Y dimensions. * @return {!print_preview.Coordinate2d} A new two-dimensional point scaled * by the given factor. */ scale(factor) { return new Coordinate2d(this.x_ * factor, this.y_ * factor); } /** * @param {print_preview.Coordinate2d} other The point to compare against. * @return {boolean} Whether another point is equal to this one. */ equals(other) { return other != null && this.x_ == other.x_ && this.y_ == other.y_; } } // Export return {Coordinate2d: Coordinate2d}; }); // // 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('print_preview', function() { 'use strict'; class Size { /** * Immutable two-dimensional size. * @param {number} width Width of the size. * @param {number} height Height of the size. */ constructor(width, height) { /** * Width of the size. * @type {number} * @private */ this.width_ = width; /** * Height of the size. * @type {number} * @private */ this.height_ = height; } /** @return {number} Width of the size. */ get width() { return this.width_; } /** @return {number} Height of the size. */ get height() { return this.height_; } /** * @param {print_preview.Size} other Other size object to compare against. * @return {boolean} Whether this size object is equal to another. */ equals(other) { return other != null && this.width_ == other.width_ && this.height_ == other.height_; } } // Export return {Size: Size}; }); // // 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('print_preview', function() { 'use strict'; /* Mutable reference to a CDD object. */ class CapabilitiesHolder { constructor() { /** * Reference to the capabilities object. * @private {?print_preview.Cdd} */ this.capabilities_ = null; } /** @return {?print_preview.Cdd} The instance held by the holder. */ get() { return this.capabilities_; } /** * @param {!print_preview.Cdd} capabilities New instance to put into the * holder. */ set(capabilities) { this.capabilities_ = capabilities; } } // Export return {CapabilitiesHolder: CapabilitiesHolder}; }); // // 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('print_preview', function() { 'use strict'; class UserInfo extends cr.EventTarget { /** * Repository which stores information about the user. Events are dispatched * when the information changes. */ constructor() { super(); /** * Email address of the logged in user or {@code null} if no user is * logged in. In case of Google multilogin, can be changed by the user. * @private {?string} */ this.activeUser_ = null; /** * Email addresses of the logged in users or empty array if no user is * logged in. {@code null} if not known yet. * @private {?Array} */ this.users_ = null; } /** @return {boolean} Whether user accounts are already retrieved. */ get initialized() { return this.users_ != null; } /** @return {boolean} Whether user is logged in or not. */ get loggedIn() { return !!this.activeUser; } /** * @return {?string} Email address of the logged in user or {@code null} if * no user is logged. */ get activeUser() { return this.activeUser_; } /** * Changes active user. * @param {?string} activeUser Email address for the user to be set as * active. */ set activeUser(activeUser) { if (!!activeUser && this.activeUser_ != activeUser) { this.activeUser_ = activeUser; cr.dispatchSimpleEvent(this, UserInfo.EventType.ACTIVE_USER_CHANGED); } } /** * @return {?Array} Email addresses of the logged in users or * empty array if no user is logged in. {@code null} if not known yet. */ get users() { return this.users_; } /** * Sets logged in user accounts info. * @param {string} activeUser Active user account (email). * @param {!Array} users List of currently logged in accounts. */ setUsers(activeUser, users) { this.activeUser_ = activeUser; this.users_ = users || []; cr.dispatchSimpleEvent(this, UserInfo.EventType.USERS_CHANGED); } } /** * Enumeration of event types dispatched by the user info. * @enum {string} */ UserInfo.EventType = { ACTIVE_USER_CHANGED: 'print_preview.UserInfo.ACTIVE_USER_CHANGED', USERS_CHANGED: 'print_preview.UserInfo.USERS_CHANGED' }; return {UserInfo: UserInfo}; }); // // 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.exportPath('print_preview'); /** * Enumeration of field names for serialized app state. * @enum {string} */ print_preview.AppStateField = { VERSION: 'version', RECENT_DESTINATIONS: 'recentDestinations', DPI: 'dpi', MEDIA_SIZE: 'mediaSize', MARGINS_TYPE: 'marginsType', CUSTOM_MARGINS: 'customMargins', IS_COLOR_ENABLED: 'isColorEnabled', IS_DUPLEX_ENABLED: 'isDuplexEnabled', IS_HEADER_FOOTER_ENABLED: 'isHeaderFooterEnabled', IS_LANDSCAPE_ENABLED: 'isLandscapeEnabled', IS_COLLATE_ENABLED: 'isCollateEnabled', IS_FIT_TO_PAGE_ENABLED: 'isFitToPageEnabled', IS_CSS_BACKGROUND_ENABLED: 'isCssBackgroundEnabled', SCALING: 'scaling', VENDOR_OPTIONS: 'vendorOptions' }; cr.define('print_preview', function() { 'use strict'; class AppState extends cr.EventTarget { /** * Object used to get and persist the print preview application state. * @param {!print_preview.DestinationStore} destinationStore The destination * store, used to track destination selection changes. */ constructor(destinationStore) { super(); /** * Internal representation of application state. * Must contain only plain objects or classes that override the * toJSON() method. * @private {!Object} */ this.state_ = {}; this.state_[print_preview.AppStateField.VERSION] = AppState.VERSION_; this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] = []; /** * Whether the app state has been initialized. The app state will ignore * all writes until it has been initialized. * @private {boolean} */ this.isInitialized_ = false; /** * Native Layer object to use for sending app state to C++ handler. * @private {!print_preview.NativeLayer} */ this.nativeLayer_ = print_preview.NativeLayer.getInstance(); /** * Destination store object for tracking recent destinations. * @private {!print_preview.DestinationStore} */ this.destinationStore_ = destinationStore; /** * Event tracker used to track event listeners. * @private {!EventTracker} */ this.tracker_ = new EventTracker(); } /** * @return {?print_preview.RecentDestination} The most recent * destination, which is currently the selected destination. */ get selectedDestination() { return (this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] .length > 0) ? this.state_[print_preview.AppStateField.RECENT_DESTINATIONS][0] : null; } /** * @return {?Array} The * AppState.NUM_DESTINATIONS_ most recent destinations. */ get recentDestinations() { return this.state_[print_preview.AppStateField.RECENT_DESTINATIONS]; } /** * @param {!print_preview.AppStateField} field App state field to check if * set. * @return {boolean} Whether a field has been set in the app state. */ hasField(field) { return this.state_.hasOwnProperty(field); } /** * @param {!print_preview.AppStateField} field App state field to get. * @return {?} Value of the app state field. */ getField(field) { if (field == print_preview.AppStateField.CUSTOM_MARGINS) { return this.state_[field] ? print_preview.Margins.parse(this.state_[field]) : null; } return this.state_[field]; } /** * Initializes the app state from a serialized string returned by the native * layer. * @param {?string} serializedAppStateStr Serialized string representation * of the app state. */ init(serializedAppStateStr) { if (serializedAppStateStr) { try { const state = JSON.parse(serializedAppStateStr); if (!!state && state[print_preview.AppStateField.VERSION] == AppState.VERSION_) { this.state_ = /** @type {!Object} */ (state); } } catch (e) { console.error('Unable to parse state: ' + e); // Proceed with default state. } } else { // Set some state defaults. this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] = []; } if (!this.state_[print_preview.AppStateField.RECENT_DESTINATIONS]) { this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] = []; } else if (!(this.state_[print_preview.AppStateField .RECENT_DESTINATIONS] instanceof Array)) { const tmp = this.state_[print_preview.AppStateField.RECENT_DESTINATIONS]; this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] = [tmp]; } else if ( !this.state_[print_preview.AppStateField.RECENT_DESTINATIONS][0] || !this.state_[print_preview.AppStateField.RECENT_DESTINATIONS][0].id) { // read in incorrectly this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] = []; } else if ( this.state_[print_preview.AppStateField.RECENT_DESTINATIONS].length > AppState.NUM_DESTINATIONS_) { this.state_[print_preview.AppStateField.RECENT_DESTINATIONS].length = AppState.NUM_DESTINATIONS_; } } /** * Sets to initialized state. Now object will accept persist requests and * monitor for destination changes. */ setInitialized() { this.isInitialized_ = true; this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType .SELECTED_DESTINATION_CAPABILITIES_READY, this.persistSelectedDestination_.bind(this)); this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType.DESTINATION_SELECT, this.persistSelectedDestination_.bind(this)); } /** * Persists the given value for the given field. * @param {!print_preview.AppStateField} field Field to persist. * @param {?} value Value of field to persist. */ persistField(field, value) { if (!this.isInitialized_) return; if (field == print_preview.AppStateField.CUSTOM_MARGINS) { this.state_[field] = value ? value.serialize() : null; } else { this.state_[field] = value; } this.persist_(); } /** * Persists the selected destination from the destination store. * @private */ persistSelectedDestination_() { assert(this.isInitialized_); const destination = this.destinationStore_.selectedDestination; if (!destination) return; // Determine if this destination is already in the recent destinations, // and where in the array it is located. const newDestination = print_preview.makeRecentDestination(assert(destination)); let indexFound = this.state_[print_preview.AppStateField.RECENT_DESTINATIONS] .findIndex(function(recent) { return ( newDestination.id == recent.id && newDestination.origin == recent.origin); }); // No change if (indexFound == 0 && this.selectedDestination.capabilities == newDestination.capabilities) { this.persist_(); return; } // Shift the array so that the nth most recent destination is located at // index n. if (indexFound == -1 && this.state_[print_preview.AppStateField.RECENT_DESTINATIONS].length == AppState.NUM_DESTINATIONS_) { indexFound = AppState.NUM_DESTINATIONS_ - 1; } if (indexFound != -1) this.state_[print_preview.AppStateField.RECENT_DESTINATIONS].splice( indexFound, 1); // Add the most recent destination this.state_[print_preview.AppStateField.RECENT_DESTINATIONS].splice( 0, 0, newDestination); this.persist_(); } /** * Calls into the native layer to persist the application state. * @private */ persist_() { this.nativeLayer_.saveAppState(JSON.stringify(this.state_)); } } /** * Number of recent print destinations to store across browser sessions. * @const {number} * @private */ AppState.NUM_DESTINATIONS_ = 3; /** * Current version of the app state. This value helps to understand how to * parse earlier versions of the app state. * @const {number} * @private */ AppState.VERSION_ = 2; return { AppState: AppState, }; }); // // 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 {Object|number|boolean|string} */ print_preview.ValueType; cr.define('print_preview.ticket_items', function() { 'use strict'; class TicketItem extends cr.EventTarget { /** * An object that represents a user modifiable item in a print ticket. Each * ticket item has a value which can be set by the user. Ticket items can * also be unavailable for modifying if the print destination doesn't * support it or if other ticket item constraints are not met. * @param {?print_preview.AppState} appState Application state model to * update * when ticket items update. * @param {?print_preview.AppStateField} field Field of the app state to * update when ticket item is updated. * @param {?print_preview.DestinationStore} destinationStore Used listen for * changes in the currently selected destination's capabilities. Since * this is a common dependency of ticket items, it's handled in the base * class. * @param {?print_preview.DocumentInfo=} opt_documentInfo Used to listen for * changes in the document. Since this is a common dependency of ticket * items, it's handled in the base class. */ constructor(appState, field, destinationStore, opt_documentInfo) { super(); /** * Application state model to update when ticket items update. * @type {print_preview.AppState} * @private */ this.appState_ = appState || null; /** * Field of the app state to update when ticket item is updated. * @type {?print_preview.AppStateField} * @private */ this.field_ = field || null; /** * Used listen for changes in the currently selected destination's * capabilities. * @type {print_preview.DestinationStore} * @private */ this.destinationStore_ = destinationStore || null; /** * Used to listen for changes in the document. * @type {print_preview.DocumentInfo} * @private */ this.documentInfo_ = opt_documentInfo || null; /** * Backing store of the print ticket item. * @type {Object} * @private */ this.value_ = null; /** * Keeps track of event listeners for the ticket item. * @type {!EventTracker} * @private */ this.tracker_ = new EventTracker(); this.addEventHandlers_(); } /** * Determines whether a given value is valid for the ticket item. * @param {?} value The value to check for validity. * @return {boolean} Whether the given value is valid for the ticket item. */ wouldValueBeValid(value) { throw Error('Abstract method not overridden'); } /** * @return {boolean} Whether the print destination capability is available. */ isCapabilityAvailable() { throw Error('Abstract method not overridden'); } /** @return {print_preview.ValueType} The value of the ticket item. */ getValue() { if (!this.isCapabilityAvailable()) { return this.getCapabilityNotAvailableValueInternal(); } if (this.value_ == null) { return this.getDefaultValueInternal(); } return this.value_; } /** @return {boolean} Whether the ticket item was modified by the user. */ isUserEdited() { return this.value_ != null; } /** @return {boolean} Whether the ticket item's value is valid. */ isValid() { if (!this.isUserEdited()) { return true; } return this.wouldValueBeValid(this.value_); } /** * @param {?} value Value to compare to the value of this ticket item. * @return {boolean} Whether the given value is equal to the value of the * ticket item. */ isValueEqual(value) { return this.getValue() == value; } /** * @param {?} value Value to set as the value of the ticket item. */ updateValue(value) { // Use comparison with capabilities for event. const sendUpdateEvent = !this.isValueEqual(value); // Don't lose requested value if capability is not available. this.updateValueInternal(value); if (this.appState_ && (this.field_ != null) && (this.field_ != print_preview.AppStateField.SCALING || this.wouldValueBeValid(value))) { this.appState_.persistField(this.field_, value); } if (sendUpdateEvent) cr.dispatchSimpleEvent(this, TicketItem.EventType.CHANGE); } /** * @return {?} Default value of the ticket item if no value was set by * the user. * @protected */ getDefaultValueInternal() { throw Error('Abstract method not overridden'); } /** * @return {?} Default value of the ticket item if the capability is * not available. * @protected */ getCapabilityNotAvailableValueInternal() { throw Error('Abstract method not overridden'); } /** * @return {!EventTracker} Event tracker to keep track of events from * dependencies. * @protected */ getTrackerInternal() { return this.tracker_; } /** * @return {print_preview.Destination} Selected destination from the * destination store, or {@code null} if no destination is selected. * @protected */ getSelectedDestInternal() { return this.destinationStore_ ? this.destinationStore_.selectedDestination : null; } /** * @return {print_preview.DocumentInfo} Document data model. * @protected */ getDocumentInfoInternal() { return this.documentInfo_; } /** * Dispatches a CHANGE event. * @protected */ dispatchChangeEventInternal() { cr.dispatchSimpleEvent( this, print_preview.ticket_items.TicketItem.EventType.CHANGE); } /** * Updates the value of the ticket item without dispatching any events or * persisting the value. * @protected */ updateValueInternal(value) { this.value_ = value; } /** * Adds event handlers for this class. * @private */ addEventHandlers_() { if (this.destinationStore_) { this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType .SELECTED_DESTINATION_CAPABILITIES_READY, this.dispatchChangeEventInternal.bind(this)); } if (this.documentInfo_) { this.tracker_.add( this.documentInfo_, print_preview.DocumentInfo.EventType.CHANGE, this.dispatchChangeEventInternal.bind(this)); } } } /** * Event types dispatched by this class. * @enum {string} */ TicketItem.EventType = { CHANGE: 'print_preview.ticket_items.TicketItem.CHANGE' }; // Export return {TicketItem: TicketItem}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; const CustomMarginsOrientation = print_preview.ticket_items.CustomMarginsOrientation; class CustomMargins extends print_preview.ticket_items.TicketItem { /** * Custom page margins ticket item whose value is a * {@code print_preview.Margins}. * @param {!print_preview.AppState} appState App state used to persist * custom margins. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. */ constructor(appState, documentInfo) { super( appState, print_preview.AppStateField.CUSTOM_MARGINS, null /*destinationStore*/, documentInfo); } /** @override */ wouldValueBeValid(value) { const margins = /** @type {!print_preview.Margins} */ (value); for (const key in CustomMarginsOrientation) { const o = CustomMarginsOrientation[key]; const max = this.getMarginMax_( o, margins.get(CustomMargins.OppositeOrientation_[o])); if (margins.get(o) > max || margins.get(o) < 0) { return false; } } return true; } /** @override */ isCapabilityAvailable() { return this.getDocumentInfoInternal().isModifiable; } /** @override */ isValueEqual(value) { return this.getValue().equals(value); } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin to get the maximum value for. * @return {number} Maximum value in points of the specified margin. */ getMarginMax(orientation) { const oppositeOrient = CustomMargins.OppositeOrientation_[orientation]; const margins = /** @type {!print_preview.Margins} */ (this.getValue()); return this.getMarginMax_(orientation, margins.get(oppositeOrient)); } /** @override */ updateValue(value) { let margins = /** @type {!print_preview.Margins} */ (value); if (margins != null) { margins = new print_preview.Margins( Math.round(margins.get(CustomMarginsOrientation.TOP)), Math.round(margins.get(CustomMarginsOrientation.RIGHT)), Math.round(margins.get(CustomMarginsOrientation.BOTTOM)), Math.round(margins.get(CustomMarginsOrientation.LEFT))); } print_preview.ticket_items.TicketItem.prototype.updateValue.call( this, margins); } /** * Updates the specified margin in points while keeping the value within * a maximum and minimum. * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin to update. * @param {number} value Updated margin value in points. */ updateMargin(orientation, value) { const margins = /** @type {!print_preview.Margins} */ (this.getValue()); const oppositeOrientation = CustomMargins.OppositeOrientation_[orientation]; const max = this.getMarginMax_(orientation, margins.get(oppositeOrientation)); value = Math.max(0, Math.min(max, value)); this.updateValue(margins.set(orientation, value)); } /** @override */ getDefaultValueInternal() { return this.getDocumentInfoInternal().margins || new print_preview.Margins(72, 72, 72, 72); } /** @override */ getCapabilityNotAvailableValueInternal() { return this.getDocumentInfoInternal().margins || new print_preview.Margins(72, 72, 72, 72); } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies which margin to get the maximum value of. * @param {number} oppositeMargin Value of the margin in points * opposite the specified margin. * @return {number} Maximum value in points of the specified margin. * @private */ getMarginMax_(orientation, oppositeMargin) { const dimensionLength = (orientation == CustomMarginsOrientation.TOP || orientation == CustomMarginsOrientation.BOTTOM) ? this.getDocumentInfoInternal().pageSize.height : this.getDocumentInfoInternal().pageSize.width; const totalMargin = dimensionLength - CustomMargins.MINIMUM_MARGINS_DISTANCE_; return Math.round(totalMargin > 0 ? totalMargin - oppositeMargin : 0); } } /** * Mapping of a margin orientation to its opposite. * @type {!Object} * @private */ CustomMargins.OppositeOrientation_ = {}; CustomMargins.OppositeOrientation_[CustomMarginsOrientation.TOP] = CustomMarginsOrientation.BOTTOM; CustomMargins.OppositeOrientation_[CustomMarginsOrientation.RIGHT] = CustomMarginsOrientation.LEFT; CustomMargins.OppositeOrientation_[CustomMarginsOrientation.BOTTOM] = CustomMarginsOrientation.TOP; CustomMargins.OppositeOrientation_[CustomMarginsOrientation.LEFT] = CustomMarginsOrientation.RIGHT; /** * Minimum distance in points that two margins can be separated by. * @type {number} * @const * @private */ CustomMargins.MINIMUM_MARGINS_DISTANCE_ = 72; // 1 inch. // Export return {CustomMargins: CustomMargins}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Collate extends print_preview.ticket_items.TicketItem { /** * Collate ticket item whose value is a {@code boolean} that indicates * whether collation is enabled. * @param {!print_preview.AppState} appState App state used to persist * collate selection. * @param {!print_preview.DestinationStore} destinationStore Destination * store used determine if a destination has the collate capability. */ constructor(appState, destinationStore) { super( appState, print_preview.AppStateField.IS_COLLATE_ENABLED, destinationStore); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return !!this.getCollateCapability_(); } /** @override */ getDefaultValueInternal() { const capability = this.getCollateCapability_(); return capability.hasOwnProperty('default') ? capability.default : true; } /** @override */ getCapabilityNotAvailableValueInternal() { return true; } /** * @return {Object} Collate capability of the selected destination. * @private */ getCollateCapability_() { const dest = this.getSelectedDestInternal(); return (dest && dest.capabilities && dest.capabilities.printer && dest.capabilities.printer.collate) || null; } } // Export return {Collate: Collate}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Color extends print_preview.ticket_items.TicketItem { /** * Color ticket item whose value is a {@code boolean} that indicates whether * the document should be printed in color. * @param {!print_preview.AppState} appState App state persistence object to * save the state of the color selection. * @param {!print_preview.DestinationStore} destinationStore Used to * determine whether color printing should be available. */ constructor(appState, destinationStore) { super( appState, print_preview.AppStateField.IS_COLOR_ENABLED, destinationStore); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { const dest = this.getSelectedDestInternal(); return dest ? dest.hasColorCapability : false; } /** @override */ getDefaultValueInternal() { const dest = this.getSelectedDestInternal(); const defaultOption = dest ? dest.defaultColorOption : null; return defaultOption && (Color.COLOR_TYPES_.indexOf(defaultOption.type) >= 0); } /** @override */ getCapabilityNotAvailableValueInternal() { // TODO(rltoscano): Get rid of this check based on destination ID. These // destinations should really update their CDDs to have only one color // option that has type 'STANDARD_COLOR'. const dest = this.getSelectedDestInternal(); if (dest) { if (dest.id == print_preview.Destination.GooglePromotedId.DOCS || dest.type == print_preview.DestinationType.MOBILE) { return true; } } return this.getDefaultValueInternal(); } } /** * @private {!Array} List of capability types considered color. * @const */ Color.COLOR_TYPES_ = ['STANDARD_COLOR', 'CUSTOM_COLOR']; /** * @private {!Array} List of capability types considered monochrome. * @const */ Color.MONOCHROME_TYPES_ = ['STANDARD_MONOCHROME', 'CUSTOM_MONOCHROME']; // Export return {Color: Color}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Copies extends print_preview.ticket_items.TicketItem { /** * Copies ticket item whose value is a {@code string} that indicates how * many copies of the document should be printed. The ticket item is backed * by a string since the user can textually input the copies value. * @param {!print_preview.DestinationStore} destinationStore Destination * store used to determine if a destination has the copies capability. */ constructor(destinationStore) { super(null /*appState*/, null /*field*/, destinationStore); } /** @override */ wouldValueBeValid(value) { return value != ''; } /** @override */ isCapabilityAvailable() { return !!this.getCopiesCapability_(); } /** @return {number} The number of copies indicated by the ticket item. */ getValueAsNumber() { const value = this.getValue(); return value == '' ? 0 : parseInt(value, 10); } /** @override */ getDefaultValueInternal() { const cap = this.getCopiesCapability_(); return cap.hasOwnProperty('default') ? cap.default : '1'; } /** @override */ getCapabilityNotAvailableValueInternal() { return '1'; } /** * @return {Object} Copies capability of the selected destination. * @private */ getCopiesCapability_() { const dest = this.getSelectedDestInternal(); return (dest && dest.capabilities && dest.capabilities.printer && dest.capabilities.printer.copies) || null; } } // Export return {Copies: Copies}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Dpi extends print_preview.ticket_items.TicketItem { /** * DPI ticket item. * @param {!print_preview.AppState} appState App state used to persist DPI * selection. * @param {!print_preview.DestinationStore} destinationStore Destination * store used to determine if a destination has the DPI capability. */ constructor(appState, destinationStore) { super(appState, print_preview.AppStateField.DPI, destinationStore); } /** @override */ wouldValueBeValid(value) { if (!this.isCapabilityAvailable()) return false; return this.capability.option.some(function(option) { return option.horizontal_dpi == value.horizontal_dpi && option.vertical_dpi == value.vertical_dpi && option.vendor_id == value.vendor_id; }); } /** @override */ isCapabilityAvailable() { return !!this.capability && !!this.capability.option && this.capability.option.length > 1; } /** @override */ isValueEqual(value) { const myValue = this.getValue(); return myValue.horizontal_dpi == value.horizontal_dpi && myValue.vertical_dpi == value.vertical_dpi && myValue.vendor_id == value.vendor_id; } /** @return {Object} DPI capability of the selected destination. */ get capability() { const destination = this.getSelectedDestInternal(); return (destination && destination.capabilities && destination.capabilities.printer && destination.capabilities.printer.dpi) || null; } /** @override */ getDefaultValueInternal() { const defaultOptions = this.capability.option.filter(function(option) { return option.is_default; }); return defaultOptions.length > 0 ? defaultOptions[0] : null; } /** @override */ getCapabilityNotAvailableValueInternal() { return {}; } } // Export return {Dpi: Dpi}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Duplex extends print_preview.ticket_items.TicketItem { /** * Duplex ticket item whose value is a {@code boolean} that indicates * whether the document should be duplex printed. * @param {!print_preview.AppState} appState App state used to persist * collate selection. * @param {!print_preview.DestinationStore} destinationStore Destination * store used determine if a destination has the collate capability. */ constructor(appState, destinationStore) { super( appState, print_preview.AppStateField.IS_DUPLEX_ENABLED, destinationStore); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { const cap = this.getDuplexCapability_(); if (!cap) { return false; } let hasLongEdgeOption = false; let hasSimplexOption = false; cap.option.forEach(function(option) { hasLongEdgeOption = hasLongEdgeOption || option.type == 'LONG_EDGE'; hasSimplexOption = hasSimplexOption || option.type == 'NO_DUPLEX'; }); return hasLongEdgeOption && hasSimplexOption; } /** @override */ getDefaultValueInternal() { const cap = this.getDuplexCapability_(); const defaultOptions = cap.option.filter(function(option) { return option.is_default; }); return defaultOptions.length == 0 ? false : defaultOptions[0].type == 'LONG_EDGE'; } /** @override */ getCapabilityNotAvailableValueInternal() { return false; } /** * @return {Object} Duplex capability of the selected destination. * @private */ getDuplexCapability_() { const dest = this.getSelectedDestInternal(); return (dest && dest.capabilities && dest.capabilities.printer && dest.capabilities.printer.duplex) || null; } } // Export return {Duplex: Duplex}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class HeaderFooter extends print_preview.ticket_items.TicketItem { /** * Header-footer ticket item whose value is a {@code boolean} that indicates * whether the document should be printed with headers and footers. * @param {!print_preview.AppState} appState App state used to persist * whether header-footer is enabled. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.ticket_items.MarginsType} marginsType Ticket item * that stores which predefined margins to print with. * @param {!print_preview.ticket_items.CustomMargins} customMargins Ticket * item that stores custom margin values. * @param {!print_preview.ticket_items.MediaSize} mediaSize Ticket item that * stores media size values. * @param {!print_preview.ticket_items.Landscape} landscape Ticket item that * stores landscape values. */ constructor( appState, documentInfo, marginsType, customMargins, mediaSize, landscape) { super( appState, print_preview.AppStateField.IS_HEADER_FOOTER_ENABLED, null /*destinationStore*/, documentInfo); /** * Ticket item that stores which predefined margins to print with. * @private {!print_preview.ticket_items.MarginsType} */ this.marginsType_ = marginsType; /** * Ticket item that stores custom margin values. * @private {!print_preview.ticket_items.CustomMargins} */ this.customMargins_ = customMargins; /** * Ticket item that stores media size values. * @private {!print_preview.ticket_items.MediaSize} */ this.mediaSize_ = mediaSize; /** * Ticket item that stores landscape values. * @private {!print_preview.ticket_items.Landscape} */ this.landscape_ = landscape; this.addEventListeners_(); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { if (!this.getDocumentInfoInternal().isModifiable) { return false; } if (this.marginsType_.getValue() == print_preview.ticket_items.MarginsTypeValue.NO_MARGINS) { return false; } const microns = this.landscape_.getValue() ? this.mediaSize_.getValue().width_microns : this.mediaSize_.getValue().height_microns; if (microns < HeaderFooter.MINIMUM_HEIGHT_MICRONS_) { // If this is a small paper size, there is not space for headers // and footers regardless of the margins. return false; } if (this.marginsType_.getValue() == print_preview.ticket_items.MarginsTypeValue.MINIMUM) { return true; } let margins; if (this.marginsType_.getValue() == print_preview.ticket_items.MarginsTypeValue.CUSTOM) { if (!this.customMargins_.isValid()) { return false; } margins = this.customMargins_.getValue(); } else { margins = this.getDocumentInfoInternal().margins; } const orientEnum = print_preview.ticket_items.CustomMarginsOrientation; return margins == null || margins.get(orientEnum.TOP) > 0 || margins.get(orientEnum.BOTTOM) > 0; } /** @override */ getDefaultValueInternal() { return true; } /** @override */ getCapabilityNotAvailableValueInternal() { return false; } /** * Adds CHANGE listeners to dependent ticket items. * @private */ addEventListeners_() { this.getTrackerInternal().add( this.marginsType_, print_preview.ticket_items.TicketItem.EventType.CHANGE, this.dispatchChangeEventInternal.bind(this)); this.getTrackerInternal().add( this.customMargins_, print_preview.ticket_items.TicketItem.EventType.CHANGE, this.dispatchChangeEventInternal.bind(this)); this.getTrackerInternal().add( this.mediaSize_, print_preview.ticket_items.TicketItem.EventType.CHANGE, this.dispatchChangeEventInternal.bind(this)); this.getTrackerInternal().add( this.landscape_, print_preview.ticket_items.TicketItem.EventType.CHANGE, this.dispatchChangeEventInternal.bind(this)); } } /** * Minimum height of page in microns to allow headers and footers. Should * match the value for min_size_printer_units in printing/print_settings.cc * so that we do not request header/footer for margins that will be zero. * @private {number} * @const */ HeaderFooter.MINIMUM_HEIGHT_MICRONS_ = 25400; // Export return {HeaderFooter: HeaderFooter}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class MediaSize extends print_preview.ticket_items.TicketItem { /** * Media size ticket item. * @param {!print_preview.AppState} appState App state used to persist media * size selection. * @param {!print_preview.DestinationStore} destinationStore Destination * store used to determine if a destination has the media size * capability. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.ticket_items.MarginsType} marginsType Reset when * landscape value changes. * @param {!print_preview.ticket_items.CustomMargins} customMargins Reset * when landscape value changes. */ constructor( appState, destinationStore, documentInfo, marginsType, customMargins) { super( appState, print_preview.AppStateField.MEDIA_SIZE, destinationStore, documentInfo); /** * Margins ticket item. Reset when this item changes. * @private {!print_preview.ticket_items.MarginsType} */ this.marginsType_ = marginsType; /** * Custom margins ticket item. Reset when this item changes. * @private {!print_preview.ticket_items.CustomMargins} */ this.customMargins_ = customMargins; } /** @override */ wouldValueBeValid(value) { if (!this.isCapabilityAvailable()) { return false; } return this.capability.option.some(function(option) { return option.width_microns == value.width_microns && option.height_microns == value.height_microns && option.is_continuous_feed == value.is_continuous_feed && option.vendor_id == value.vendor_id; }); } /** @override */ isCapabilityAvailable() { const knownSizeToSaveAsPdf = (!this.getDocumentInfoInternal().isModifiable || this.getDocumentInfoInternal().hasCssMediaStyles) && this.getSelectedDestInternal() && this.getSelectedDestInternal().id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF; return !knownSizeToSaveAsPdf && !!this.capability; } /** @override */ isValueEqual(value) { const myValue = this.getValue(); return myValue.width_microns == value.width_microns && myValue.height_microns == value.height_microns && myValue.is_continuous_feed == value.is_continuous_feed && myValue.vendor_id == value.vendor_id; } /** @return {Object} Media size capability of the selected destination. */ get capability() { const destination = this.getSelectedDestInternal(); return (destination && destination.capabilities && destination.capabilities.printer && destination.capabilities.printer.media_size) || null; } /** @override */ getDefaultValueInternal() { const defaultOptions = this.capability.option.filter(function(option) { return option.is_default; }); return defaultOptions.length > 0 ? defaultOptions[0] : null; } /** @override */ getCapabilityNotAvailableValueInternal() { return {}; } /** @override */ updateValueInternal(value) { const updateMargins = !this.isValueEqual(value); print_preview.ticket_items.TicketItem.prototype.updateValueInternal.call( this, value); if (updateMargins) { // Reset the user set margins when media size changes. this.marginsType_.updateValue( print_preview.ticket_items.MarginsTypeValue.DEFAULT); this.customMargins_.updateValue(null); } } } // Export return {MediaSize: MediaSize}; }); // // Copyright (c) 2016 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('print_preview.ticket_items', function() { 'use strict'; class Scaling extends print_preview.ticket_items.TicketItem { /** * Scaling ticket item whose value is a {@code string} that indicates what * the scaling (in percent) of the document should be. The ticket item is * backed by a string since the user can textually input the scaling value. * @param {!print_preview.AppState} appState App state to persist item * value. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.DestinationStore} destinationStore Used to * determine whether scaling should be available. */ constructor(appState, destinationStore, documentInfo) { super( appState, print_preview.AppStateField.SCALING, destinationStore, documentInfo); } /** @override */ wouldValueBeValid(value) { return value != ''; } /** @override */ isValueEqual(value) { return this.getValue() == value; } /** @override */ isCapabilityAvailable() { // This is not a function of the printer, but should be disabled if we are // saving a PDF to a PDF. const knownSizeToSaveAsPdf = (!this.getDocumentInfoInternal().isModifiable || this.getDocumentInfoInternal().hasCssMediaStyles) && this.getSelectedDestInternal() && this.getSelectedDestInternal().id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF; return !knownSizeToSaveAsPdf; } /** @return {number} The scaling percentage indicated by the ticket item. */ getValueAsNumber() { const value = this.getValue() == '' ? 0 : parseInt(this.getValue(), 10); assert(!isNaN(value)); return value; } /** @override */ getDefaultValueInternal() { return '100'; } /** @override */ getCapabilityNotAvailableValueInternal() { return '100'; } } // Export return {Scaling: Scaling}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class Landscape extends print_preview.ticket_items.TicketItem { /** * Landscape ticket item whose value is a {@code boolean} that indicates * whether the document should be printed in landscape orientation. * @param {!print_preview.AppState} appState App state object used to * persist ticket item values. * @param {!print_preview.DestinationStore} destinationStore Destination * store used to determine the default landscape value and if landscape * printing is available. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.ticket_items.MarginsType} marginsType Reset when * landscape value changes. * @param {!print_preview.ticket_items.CustomMargins} customMargins Reset * when landscape value changes. */ constructor( appState, destinationStore, documentInfo, marginsType, customMargins) { super( appState, print_preview.AppStateField.IS_LANDSCAPE_ENABLED, destinationStore, documentInfo); /** * Margins ticket item. Reset when landscape ticket item changes. * @type {!print_preview.ticket_items.MarginsType} * @private */ this.marginsType_ = marginsType; /** * Custom margins ticket item. Reset when landscape ticket item changes. * @type {!print_preview.ticket_items.CustomMargins} * @private */ this.customMargins_ = customMargins; } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { const cap = this.getPageOrientationCapability_(); if (!cap) return false; let hasAutoOrPortraitOption = false; let hasLandscapeOption = false; cap.option.forEach(function(option) { hasAutoOrPortraitOption = hasAutoOrPortraitOption || option.type == 'AUTO' || option.type == 'PORTRAIT'; hasLandscapeOption = hasLandscapeOption || option.type == 'LANDSCAPE'; }); // TODO(rltoscano): Technically, the print destination can still change // the orientation of the print out (at least for cloud printers) if the // document is not modifiable. But the preview wouldn't update in this // case so it would be a bad user experience. return this.getDocumentInfoInternal().isModifiable && !this.getDocumentInfoInternal().hasCssMediaStyles && hasAutoOrPortraitOption && hasLandscapeOption; } /** @override */ getDefaultValueInternal() { const cap = this.getPageOrientationCapability_(); const defaultOptions = cap.option.filter(function(option) { return option.is_default; }); return defaultOptions.length == 0 ? false : defaultOptions[0].type == 'LANDSCAPE'; } /** @override */ getCapabilityNotAvailableValueInternal() { const doc = this.getDocumentInfoInternal(); return doc.hasCssMediaStyles ? (doc.pageSize.width > doc.pageSize.height) : false; } /** @override */ updateValueInternal(value) { const updateMargins = !this.isValueEqual(value); print_preview.ticket_items.TicketItem.prototype.updateValueInternal.call( this, value); if (updateMargins) { // Reset the user set margins when page orientation changes. this.marginsType_.updateValue( print_preview.ticket_items.MarginsTypeValue.DEFAULT); this.customMargins_.updateValue(null); } } /** * @return {boolean} Whether capability contains the |value|. * @param {string} value Option to check. */ hasOption(value) { const cap = this.getPageOrientationCapability_(); if (!cap) return false; return cap.option.some(function(option) { return option.type == value; }); } /** * @return {Object} Page orientation capability of the selected destination. * @private */ getPageOrientationCapability_() { const dest = this.getSelectedDestInternal(); return (dest && dest.capabilities && dest.capabilities.printer && dest.capabilities.printer.page_orientation) || null; } } // Export return {Landscape: Landscape}; }); // // 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.exportPath('print_preview.ticket_items'); /** * Must be kept in sync with the C++ MarginType enum in * printing/print_job_constants.h. * @enum {number} */ print_preview.ticket_items.MarginsTypeValue = { DEFAULT: 0, NO_MARGINS: 1, MINIMUM: 2, CUSTOM: 3 }; cr.define('print_preview.ticket_items', function() { 'use strict'; class MarginsType extends print_preview.ticket_items.TicketItem { /** * Margins type ticket item whose value is a * print_preview.ticket_items.MarginsTypeValue} that indicates what * predefined margins type to use. * @param {!print_preview.AppState} appState App state persistence object to * save the state of the margins type selection. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.ticket_items.CustomMargins} customMargins Custom * margins ticket item, used to write when margins type changes. */ constructor(appState, documentInfo, customMargins) { super( appState, print_preview.AppStateField.MARGINS_TYPE, null /*destinationStore*/, documentInfo); /** * Custom margins ticket item, used to write when margins type changes. * @type {!print_preview.ticket_items.CustomMargins} * @private */ this.customMargins_ = customMargins; } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return this.getDocumentInfoInternal().isModifiable; } /** @override */ getDefaultValueInternal() { return print_preview.ticket_items.MarginsTypeValue.DEFAULT; } /** @override */ getCapabilityNotAvailableValueInternal() { return print_preview.ticket_items.MarginsTypeValue.DEFAULT; } /** @override */ updateValueInternal(value) { print_preview.ticket_items.TicketItem.prototype.updateValueInternal.call( this, value); if (this.isValueEqual( print_preview.ticket_items.MarginsTypeValue.CUSTOM)) { // If CUSTOM, set the value of the custom margins so that it won't be // overridden by the default value. this.customMargins_.updateValue(this.customMargins_.getValue()); } } } // Export return {MarginsType: MarginsType}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class PageRange extends print_preview.ticket_items.TicketItem { /** * Page range ticket item whose value is a {@code string} that represents * which pages in the document should be printed. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. */ constructor(documentInfo) { super( null /*appState*/, null /*field*/, null /*destinationStore*/, documentInfo); } /** @override */ wouldValueBeValid(value) { const result = pageRangeTextToPageRanges( value, this.getDocumentInfoInternal().pageCount); return Array.isArray(result); } /** * @return {!print_preview.PageNumberSet} Set of page numbers defined by the * page range string. */ getPageNumberSet() { const pageNumberList = pageRangeTextToPageList( this.getValueAsString_(), this.getDocumentInfoInternal().pageCount); return new print_preview.PageNumberSet(pageNumberList); } /** @override */ isCapabilityAvailable() { return true; } /** @override */ getDefaultValueInternal() { return ''; } /** @override */ getCapabilityNotAvailableValueInternal() { return ''; } /** * @return {string} The value of the ticket item as a string. * @private */ getValueAsString_() { return /** @type {string} */ (this.getValue()); } /** * @return {!Array>} A list of page * ranges. */ getPageRanges() { const pageRanges = pageRangeTextToPageRanges(this.getValueAsString_()); return Array.isArray(pageRanges) ? pageRanges : []; } /** * @return {!Array>} A list of page * ranges suitable for use in the native layer. * TODO(vitalybuka): this should be removed when native layer switched to * page ranges. */ getDocumentPageRanges() { const pageRanges = pageRangeTextToPageRanges( this.getValueAsString_(), this.getDocumentInfoInternal().pageCount); return Array.isArray(pageRanges) ? pageRanges : []; } /** * @return {!number} Number of pages reported by the document. */ getDocumentNumPages() { return this.getDocumentInfoInternal().pageCount; } /** * @return {!PageRangeStatus} */ checkValidity() { const pageRanges = pageRangeTextToPageRanges( this.getValueAsString_(), this.getDocumentInfoInternal().pageCount); return Array.isArray(pageRanges) ? PageRangeStatus.NO_ERROR : pageRanges; } } /** * Impossibly large page number. * @type {number} * @const * @private */ PageRange.MAX_PAGE_NUMBER_ = 1000000000; // Export return {PageRange: PageRange}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class FitToPage extends print_preview.ticket_items.TicketItem { /** * Fit-to-page ticket item whose value is a {@code boolean} that indicates * whether to scale the document to fit the page. * @param {!print_preview.AppState} appState App state to persist item * value. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. * @param {!print_preview.DestinationStore} destinationStore Used to * determine whether fit to page should be available. */ constructor(appState, documentInfo, destinationStore) { super( appState, print_preview.AppStateField.IS_FIT_TO_PAGE_ENABLED, destinationStore, documentInfo); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return !this.getDocumentInfoInternal().isModifiable && (!this.getSelectedDestInternal() || this.getSelectedDestInternal().id != print_preview.Destination.GooglePromotedId.SAVE_AS_PDF); } /** @override */ getDefaultValueInternal() { // It's on by default since it is not a document feature, it is rather // a property of the printer, hardware margins limitations. User can // always override it. return true; } /** @override */ getCapabilityNotAvailableValueInternal() { return !this.getSelectedDestInternal() || this.getSelectedDestInternal().id != print_preview.Destination.GooglePromotedId.SAVE_AS_PDF; } } // Export return {FitToPage: FitToPage}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class CssBackground extends print_preview.ticket_items.TicketItem { /** * Ticket item whose value is a {@code boolean} that represents whether to * print CSS backgrounds. * @param {!print_preview.AppState} appState App state to persist CSS * background value. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. */ constructor(appState, documentInfo) { super( appState, print_preview.AppStateField.IS_CSS_BACKGROUND_ENABLED, null /*destinationStore*/, documentInfo); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return this.getDocumentInfoInternal().isModifiable; } /** @override */ getDefaultValueInternal() { return false; } /** @override */ getCapabilityNotAvailableValueInternal() { return false; } } // Export return {CssBackground: CssBackground}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class SelectionOnly extends print_preview.ticket_items.TicketItem { /** * Ticket item whose value is a {@code boolean} that represents whether to * print selection only. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print. */ constructor(documentInfo) { super( null /*appState*/, null /*field*/, null /*destinationStore*/, documentInfo); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return this.getDocumentInfoInternal().isModifiable && this.getDocumentInfoInternal().hasSelection; } /** @override */ getDefaultValueInternal() { return false; } /** @override */ getCapabilityNotAvailableValueInternal() { return false; } } // Export return {SelectionOnly: SelectionOnly}; }); // // Copyright (c) 2016 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('print_preview.ticket_items', function() { 'use strict'; class Rasterize extends print_preview.ticket_items.TicketItem { /** * Rasterize ticket item whose value is a {@code boolean} that indicates * whether the PDF document should be rendered as images. * @param {!print_preview.DocumentInfo} documentInfo Information about the * document to print, used to determine if document is a PDF. */ constructor(destinationStore, documentInfo) { super( null /* appState */, null /* field */, null /* destinationStore */, documentInfo); } /** @override */ wouldValueBeValid(value) { return true; } /** @override */ isCapabilityAvailable() { return !this.getDocumentInfoInternal().isModifiable; } /** @override */ getDefaultValueInternal() { return false; } /** @override */ getCapabilityNotAvailableValueInternal() { return this.getDefaultValueInternal(); } } // Export return {Rasterize: Rasterize}; }); // // 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('print_preview.ticket_items', function() { 'use strict'; class VendorItems extends cr.EventTarget { /** * An object that represents a user modifiable item in a print ticket. Each * ticket item has a value which can be set by the user. Ticket items can * also be unavailable for modifying if the print destination doesn't * support it or if other ticket item constraints are not met. * @param {print_preview.AppState} appState Application state model to * update when ticket items update. * @param {print_preview.DestinationStore} destinationStore Used listen for * changes in the currently selected destination's capabilities. Since * this is a common dependency of ticket items, it's handled in the * base class. */ constructor(appState, destinationStore) { super(); /** * Application state model to update when ticket items update. * @private {print_preview.AppState} */ this.appState_ = appState || null; /** * Used listen for changes in the currently selected destination's * capabilities. * @private {print_preview.DestinationStore} */ this.destinationStore_ = destinationStore || null; /** * Vendor ticket items store, maps item id to the item value. * @private {!Object} */ this.items_ = {}; } /** @return {boolean} Whether vendor capabilities are available. */ isCapabilityAvailable() { return !!this.capability; } /** @return {boolean} Whether the ticket item was modified by the user. */ isUserEdited() { // If there's at least one ticket item stored in values, it was edited. for (const key in this.items_) { if (this.items_.hasOwnProperty(key)) return true; } return false; } /** @return {Object} Vendor capabilities of the selected destination. */ get capability() { const destination = this.destinationStore_ ? this.destinationStore_.selectedDestination : null; if (!destination) return null; if (destination.type == print_preview.DestinationType.MOBILE) { return null; } return (destination.capabilities && destination.capabilities.printer && destination.capabilities.printer.vendor_capability) || null; } /** * Vendor ticket items store, maps item id to the item value. * @return {!Object} */ get ticketItems() { return this.items_; } /** * @param {!Object} values Values to set as the values of vendor * ticket items. Maps vendor item id to the value. */ updateValue(values) { this.items_ = {}; if (typeof values == 'object') { for (const key in values) { if (values.hasOwnProperty(key) && typeof values[key] == 'string') { // Let's empirically limit each value at 2K. this.items_[key] = values[key].substring(0, 2048); } } } if (this.appState_) { this.appState_.persistField( print_preview.AppStateField.VENDOR_OPTIONS, this.items_); } } } // Export return {VendorItems: VendorItems}; }); // // 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.exportPath('print_preview'); /** * @typedef {{selectSaveAsPdfDestination: boolean, * layoutSettings.portrait: boolean, * pageRange: string, * headersAndFooters: boolean, * backgroundColorsAndImages: boolean, * margins: number}} * @see chrome/browser/printing/print_preview_pdf_generated_browsertest.cc */ print_preview.PreviewSettings; /** * @typedef {{ * deviceName: string, * printerName: string, * printerDescription: (string | undefined), * cupsEnterprisePrinter: (boolean | undefined), * printerOptions: (Object | undefined), * }} */ print_preview.LocalDestinationInfo; /** * @typedef {{ * isInKioskAutoPrintMode: boolean, * isInAppKioskMode: boolean, * thousandsDelimeter: string, * decimalDelimeter: string, * unitType: !print_preview.MeasurementSystemUnitType, * previewModifiable: boolean, * documentTitle: string, * documentHasSelection: boolean, * shouldPrintSelectionOnly: boolean, * printerName: string, * serializedAppStateStr: ?string, * serializedDefaultDestinationSelectionRulesStr: ?string, * }} * @see corresponding field name definitions in print_preview_handler.cc */ print_preview.NativeInitialSettings; /** * @typedef {{ * serviceName: string, * name: string, * hasLocalPrinting: boolean, * isUnregistered: boolean, * cloudID: string, * }} * @see PrintPreviewHandler::FillPrinterDescription in print_preview_handler.cc */ print_preview.PrivetPrinterDescription; /** * @typedef {{ * printer:(print_preview.PrivetPrinterDescription | * print_preview.LocalDestinationInfo | * undefined), * capabilities: !print_preview.Cdd, * }} */ print_preview.CapabilitiesResponse; /** * @typedef {{ * printerId: string, * success: boolean, * capabilities: Object, * }} */ print_preview.PrinterSetupResponse; /** * @typedef {{ * extensionId: string, * extensionName: string, * id: string, * name: string, * description: (string|undefined), * }} */ print_preview.ProvisionalDestinationInfo; /** * Printer types for capabilities and printer list requests. * Should match PrinterType in print_preview_handler.h * @enum {number} */ print_preview.PrinterType = { PRIVET_PRINTER: 0, EXTENSION_PRINTER: 1, PDF_PRINTER: 2, LOCAL_PRINTER: 3, }; cr.define('print_preview', function() { 'use strict'; /** * An interface to the native Chromium printing system layer. */ class NativeLayer { /** * Creates a new NativeLayer if the current instance is not set. * @return {!print_preview.NativeLayer} The singleton instance. */ static getInstance() { if (currentInstance == null) currentInstance = new NativeLayer(); return assert(currentInstance); } /** * @param {!print_preview.NativeLayer} instance The NativeLayer instance * to set for print preview construction. */ static setInstance(instance) { currentInstance = instance; } /** * Requests access token for cloud print requests. * @param {string} authType type of access token. * @return {!Promise} */ getAccessToken(authType) { return cr.sendWithPromise('getAccessToken', authType); } /** * Gets the initial settings to initialize the print preview with. * @return {!Promise} */ getInitialSettings() { return cr.sendWithPromise('getInitialSettings'); } /** * Requests the system's print destinations. The promise will be resolved * when all destinations of that type have been retrieved. One or more * 'printers-added' events may be fired in response before resolution. * @param {!print_preview.PrinterType} type The type of destinations to * request. * @return {!Promise} */ getPrinters(type) { return cr.sendWithPromise('getPrinters', type); } /** * Requests the destination's printing capabilities. Returns a promise that * will be resolved with the capabilities if they are obtained successfully. * @param {string} destinationId ID of the destination. * @param {!print_preview.PrinterType} type The destination's printer type. * @return {!Promise} */ getPrinterCapabilities(destinationId, type) { return cr.sendWithPromise( 'getPrinterCapabilities', destinationId, destinationId == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? print_preview.PrinterType.PDF_PRINTER : type); } /** * Requests Chrome to resolve provisional extension destination by granting * the provider extension access to the printer. * @param {string} provisionalDestinationId * @return {!Promise} */ grantExtensionPrinterAccess(provisionalDestinationId) { return cr.sendWithPromise('grantExtensionPrinterAccess', provisionalDestinationId); } /** * Requests that Chrome peform printer setup for the given printer. * @param {string} printerId * @return {!Promise} */ setupPrinter(printerId) { return cr.sendWithPromise('setupPrinter', printerId); } /** * Requests that a preview be generated. The following Web UI events may * be triggered in response: * 'print-preset-options', * 'page-count-ready', * 'page-layout-ready', * 'page-preview-ready' * @param {string} printTicket JSON print ticket for the request. * @param {number} pageCount Page count for the preview request, or -1 if * unknown (first request). * @return {!Promise} Promise that resolves with the unique ID of * the preview UI when the preview has been generated. */ getPreview(printTicket, pageCount) { return cr.sendWithPromise('getPreview', printTicket, pageCount); } /** * Requests that the document be printed. * @param {string} printTicket The serialized print ticket for the print * job. * @return {!Promise} Promise that will resolve when the print request is * finished or rejected. */ print(printTicket) { return cr.sendWithPromise('print', printTicket); } /** Requests that the current pending print request be cancelled. */ cancelPendingPrintRequest() { chrome.send('cancelPendingPrintRequest'); } /** * Sends the app state to be saved in the sticky settings. * @param {string} appStateStr JSON string of the app state to persist. */ saveAppState(appStateStr) { chrome.send('saveAppState', [appStateStr]); } /** Shows the system's native printing dialog. */ showSystemDialog() { assert(!cr.isWindows); chrome.send('showSystemDialog'); } /** * Closes the print preview dialog. * If |isCancel| is true, also sends a message to Print Preview Handler in * order to update UMA statistics. * @param {boolean} isCancel whether this was called due to the user * closing the dialog without printing. */ dialogClose(isCancel) { if (isCancel) chrome.send('closePrintPreviewDialog'); chrome.send('dialogClose'); } /** Hide the print preview dialog and allow the native layer to close it. */ hidePreview() { chrome.send('hidePreview'); } /** * Opens the Google Cloud Print sign-in tab. The DESTINATIONS_RELOAD event * will be dispatched in response. * @param {boolean} addAccount Whether to open an 'add a new account' or * default sign in page. * @return {!Promise} Promise that resolves when the sign in tab has been * closed and the destinations should be reloaded. */ signIn(addAccount) { return cr.sendWithPromise('signIn', addAccount); } /** * Navigates the user to the Chrome printing setting page to manage local * printers and Google cloud printers. */ managePrinters() { chrome.send('managePrinters'); } /** Forces browser to open a new tab with the given URL address. */ forceOpenNewTab(url) { chrome.send('forceOpenNewTab', [url]); } /** * Sends a message to the test, letting it know that an * option has been set to a particular value and that the change has * finished modifying the preview area. */ uiLoadedForTest() { chrome.send('UILoadedForTest'); } /** * Notifies the test that the option it tried to change * had not been changed successfully. */ uiFailedLoadingForTest() { chrome.send('UIFailedLoadingForTest'); } /** * Notifies the metrics handler to record a histogram value. * @param {string} histogram The name of the histogram to record * @param {number} bucket The bucket to record * @param {number} maxBucket The maximum bucket value in the histogram. */ recordInHistogram(histogram, bucket, maxBucket) { chrome.send( 'metricsHandler:recordInHistogram', [histogram, bucket, maxBucket]); } } /** @private {?print_preview.NativeLayer} */ let currentInstance = null; /** * Version of the serialized state of the print preview. * @type {number} * @const * @private */ NativeLayer.SERIALIZED_STATE_VERSION_ = 1; // Export return { NativeLayer: NativeLayer }; }); // // 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. // Counter used to give animations unique names. let animationCounter = 0; const animationEventTracker = new EventTracker(); function addAnimation(code) { const name = 'anim' + animationCounter; animationCounter++; const rules = document.createTextNode('@keyframes ' + name + ' {' + code + '}'); const el = document.createElement('style'); el.type = 'text/css'; el.appendChild(rules); el.setAttribute('id', name); document.body.appendChild(el); return name; } /** * Generates css code for fading in an element by animating the height. * @param {number} targetHeight The desired height in pixels after the animation * ends. * @return {string} The css code for the fade in animation. */ function getFadeInAnimationCode(targetHeight) { return '0% { opacity: 0; height: 0; } ' + '80% { opacity: 0.5; height: ' + (targetHeight + 4) + 'px; }' + '100% { opacity: 1; height: ' + targetHeight + 'px; }'; } /** * Fades in an element. Used for both printing options and error messages * appearing underneath the textfields. * @param {HTMLElement} el The element to be faded in. * @param {boolean=} opt_justShow Whether {@code el} should be shown with no * animation. */ function fadeInElement(el, opt_justShow) { if (el.classList.contains('visible')) return; el.classList.remove('closing'); el.hidden = false; el.setAttribute('aria-hidden', 'false'); el.style.height = 'auto'; const height = el.offsetHeight; if (opt_justShow) { el.style.height = ''; el.style.opacity = ''; } else { el.style.height = height + 'px'; const animName = addAnimation(getFadeInAnimationCode(height)); animationEventTracker.add( el, 'animationend', onFadeInAnimationEnd.bind(el), false); el.style.animationName = animName; } el.classList.add('visible'); } /** * Fades out an element. Used for both printing options and error messages * appearing underneath the textfields. * @param {HTMLElement} el The element to be faded out. */ function fadeOutElement(el) { if (!el.classList.contains('visible')) return; fadeInAnimationCleanup(el); el.style.height = 'auto'; const height = el.offsetHeight; el.style.height = height + 'px'; /** @suppress {suspiciousCode} */ el.offsetHeight; // Should force an update of the computed style. animationEventTracker.add( el, 'transitionend', onFadeOutTransitionEnd.bind(el), false); el.classList.add('closing'); el.classList.remove('visible'); el.setAttribute('aria-hidden', 'true'); } /** * Executes when a fade out animation ends. * @param {Event} event The event that triggered this listener. * @this {HTMLElement} The element where the transition occurred. */ function onFadeOutTransitionEnd(event) { if (event.propertyName != 'height') return; animationEventTracker.remove(this, 'transitionend'); this.hidden = true; } /** * Executes when a fade in animation ends. * @param {Event} event The event that triggered this listener. * @this {HTMLElement} The element where the transition occurred. */ function onFadeInAnimationEnd(event) { this.style.height = ''; fadeInAnimationCleanup(this); } /** * Removes the
PNG  IHDRJ~s2IDATx^j@'Bz+GУ.DX!`c[4mi>6|, ~{; vR:o nIJ[(ڠ|&YL<bH=+? MF}ɭ|/!N‘ȹjbcm"/^Qͳ D9(|w&+Q KُbCoŊg;u6!2n6< E)^r1).1H? .0@H8E?MBrfD4ꕩ[چ]@!F ȔćiT;-}IENDB`PNG  IHDR00WtEXtSoftwareAdobe ImageReadyqe<fiTXtXML:com.adobe.xmp ]~xIDATxYO@>!PT& tbgbЁ0#1f`(NY~ٱuڞt8ΎyjKIyo.4///K"lIܴzekkK @)],c ;oP=w8N,aY٦8 RE\.\y}lV5=)VI * ުDY$4S\b RIGR ]M=Zu3&uN#%|9=x[2G±;"*t/ExSʨ0?%PM80XĊ#֒.{ 8c p{817W*-FĜ \ͤbŭҞκ<0e~A4$̰@6M5' p]wpww$1Qs4[1}`Aؠk/}$ %Q1hy NcP}[[F,hE.kΜ9DVIENDB`PNG  IHDRw=tIDATx^ŕMkQS7kW pS.ą.. ꢸm̈tcP(AgIf2뼇 dA={r= ? Ćdc"z'd"it:^frah^S0rQyAoSXlEdo v͡)9{.M}H (T,>*9> MEdŧ5\{gL}*wr\cq ZͦPבJyRrS0-`eO:/A<'/B6ِkjG~17[ c5Z]e:>N/ ޏF î~Wl`^WkRm˜99K}RyMd%X .Pq!nܪOt˜9I͗ gg9Q1V +I[IDATxZ[lU;{aٖ-ZQk5h*Dcj !c"1>1d?cQ;㵧D4ř3gڧލ$TU1F{Cs44t+z#08Ǒ:V5z7oJcC(]iMA)xBԄ]=mmkk+[nCHH LKC|F.ʣY{4`!] nE' eB߶nGsC^-wILgff*%{^TGaؙW-XtSy%;::U@-zm1UJUTi^幾6ҋNH<8xli#ϾwBƫĘLE< xR`w~].$/o c~ne'Tۅ|j/=vcfY(~r DF-bF6A&4,/ %;Gsk x.r@Ťrv%>Z}i̥K[..IENDB` PNG  IHDRJ~sIIDATx^J@@[]n|}RBMfFnZ-JRAbMFZ9uFbͽg10„&ddHM M4x)(t>T:/"+]ԽE,|dF,0xOXo _=DaSEp/\㲍"+l*F25qb>(*1tSAQ#U~2F<K 3Z&e8 ew6KQ-+ɑxGaXˋN[i,%Ri|֌LĒ?XfA7.3vIENDB`PNG  IHDRJ~sIDATx^O `AȰl;A'KW΍$Z"IpDY9{b'H'@m#*} $\l 8NmX4[{ ȁ9KF>2JJ<=*ltKpS0LF/yUidIENDB`PNG  IHDRJ~sIDATx= @"$V *Q,ih &k,ba&_]‡y4p? ǏC xvy*+JVܖ 9FQ{MKriH A#pG&\Ep5.'dig2ȱF\X`f8` ڙa{[RCؠ?R/nt^</N ZP2ɋ~^=)oZIENDB`PNG  IHDRw=bIDATx^ŕABNKm,-`a漜g! ZXb! oȥbHT'FBC|dvy3U&늧ey_L8|<L&R,{;79l]XlX,b8Y{8 vj ,2#0r'޲iR@1ʹ a/peWCG,{(_ ]{`l~ZN šs;'P_TUDWHs3Gc0ĽpG"kaZ%; : !4͙Nϒ_TW"uZεt3c3v| x,Sq~iW kɵW0 k$ )h468>uxq鉆 6 }$kqMf6h[6A4uB!a˲p+3&B pfm?߯QPP&S>6SW5f2=EQUUA3"^ fm?#888B\.mFDծIENDB`Vn8)Cl'UanmoEPdB9Z- {ާ'X7 RJ?^exxX 5<~TzPAYҙCș:yb?DaAYJ1iGBZSA$iW|*F7$KU 2ggT<.Tpg˒.B$.ɭY: lH{ W =*z0y켬+-<2VWJRv uv1mAN}* 5lsc 0ZƑD <\+sF:6d^*JLٖn'eaM[)dr7`{No6ƨ&#To/iB5\dYZqܞ؂56: )bZ_zjCC;R܎p'hEKULt4:dsGBc!nݱ r3V~\EM^TݴYBo պ^棣I3{K@!-hDafG5vf}W=oM4ĉXM ƚSdv ]Vu{@QR M>eH8N[< %?H K{A0%ƭ_d n2Ụ^C;6~!ju[fC0QORR~#˰|k \:z&4ww>'w2=۵IO ÕxR{mcMU̒D ~iK Xo6_1 y7VE\&6hv/HvqEhؕE7CR\93? VUip]*.[SJ3*L\=";:9/\)-[seW5/`Wl+ybdrVÂRuuw7o`)*%-kDn#F}am!QJ"4J>i66K4rVUHޔ&ګa+%H54i+fG0xB5 0Q)r[}ISϸ/*I lQql鞬-X]T\!{Ye'mU9&өoJStGo G3찕hMU0U/{wxwnxYG.nkՒpjjUCjYYw:n (Y^P&TZ ?g޾uW[Ɗ 6*Mr!_cHyG8xfZqh O+GBb iȬ6<8UKVF&ɪ kY #TmyZW2kWn,L5&hP︁Os -kiZ+ /;Do>s"?7L5|gwel/XrE$%'o>,=7'tE&%.m,`5(o86o,WtZ0)ȵ}#!3O C5r*sԬU"n&ۺi?K:\ܭyEB[䘳V*.w]uv&n rdwJ{U(׈coyOzi@k$הPxsYܝDbP8F eX!TLc / 4USPf;l# l-/ 39sȫnsLV= @8>(Pڛ|y0z'L`ڳZ',X#?e($\`Sb`2hp_JҮZPU;ͮ0g[-TN5Sy4=~c"qi;YMg4Qډ:V3t] ?|AXBRXi౰Ҏʙ{:& wیv8X#4lb;h7޸Ev }#+ ;ꋪzX*MSm6E=4tvDn^h,Þwޛ35=8Dfɖ/% MC/߹"j凛J5VӔTi~g3T.k4n5|\`^hڝ ^hrA:b灻ftY * Wfkysvu^p xmWI im , |$J^rF2vNP@$0( 5b9U,N¹EqNnH\ /ʥ)3 &؏X[0dXspG$j+4ΊΧCB^<̣mm5CMS2#b6{-E#DB ԋ;W&VIZ1c{ej h(:{玃մ+eKm_q᫦hyY ~]uDu<(~ wᕟgb t\[o8~/,&+;-26If:P"ӶYTR}ER"eY% $&y<<%iXp:cho{~at:#NLbC;$i7SO)7܅$S}J l'/±R1B_ a<_`#](oH؏a3' &bN=?e N3L#5brLO!.)}R UtwGޙ<ܚBC@'> uT\]8C$j)ނ$,a6yDWx4t 9{|+;˜M)- 'as%9b5HDg5VMH˚y)/HN3^=$Y`–O\hK~ƭmK jyNj N(f:#DJj+oIo 8QOs^ Uq/p?^q ϗWGDҒ ΟGEAbl~d<ĈOmBr ]%:^3W=b:ɲj†9ϖď(NUj8֌3j,;Բ2."Z4DhyÏ@jE#PI5? e 7 lƉܫY l[Ew%]uJ="\֭+5"J)l 9{$VNVc5ărY==- JВ0tf($^W+*C2nYkBOڴ@Y5fKi_%;R*#6P>XLj-t"yЈu{nҦgĊEedtJU0$1*[2h+ҕ+oLu ee1!a/+Ab$=Ω%qKZLU-NV-!y0-FI{9y_3x+Y0"za]pNi3DVNO_e gkq[Zhe&Fʡx`g oaUWdnB@JXDKG''ӔiWZjPi( I`V&{HڈT'SSҸ˄,uӸˉ Ҟ/ #h)7IZ<27Bp8)90sz~;B}ѹ)o +L2Z56NtKrװX''92a3#ӈ>d[G919! $9]kxC\̄t\flcrѦ[ʶ[EIlVۍ짶˩˗|FR}f۠TRfCw;{m-lӞDWzwP^]ʼnYBB,oeNP!yZm_-Wz 4Q{F ."cq0,$[WNB6$W%= u8hߜJU [Dȳ h>rTNT5BW`")Lwi-Sצ2qa1C*!Ǝ|O׮Tv$Fe}*OIy4:jKT,lTI8x]A@yް -kxVE!HD[ n|/< ݥuod-6n9j u! ZF@Yu NNakvd%S cy6Hf̰I+>PQ@641 ̹ [U5"hiToie(?x*ۛ)@t*K~WmBHCUKo@GUj yUH\Z1lYhw  mp0y|kO ?9'%gyi&R΅gAF,9>t`df5hY!r F+`p1zJ bAnf :\E up 9HZ%hxjZ]+H E)ɟrvAZ{Kc CHfշeE"@Z%SEԲɥrɋgf0|NI>C}-x[qN)Uk2cjMpċ"kJj9Z.XؾAX &xƌT{,*5]5TmӪMM5UIo;N525_RE*6uٜ-^o3a"QVu#!؆&ogx^xwi-'Np+kt՝mW` KẃvJ31~a ?7|~X%jkb]q\['nhhRFr7.dE TÐPK2>g{=LKhp*Y*n00){ɮɡ]%7H$:]6m/* 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. */ .picture img { border-radius: 50%; max-height: 100%; max-width: 100%; } .details { padding: 0 24px; } #picture-container { align-items: center; display: flex; justify-content: center; padding-bottom: 32px; padding-top: 24px; } .picture { -webkit-margin-start: 84px; height: 96px; position: relative; width: 96px; } #profile-picture, .checkmark-circle { position: absolute; } .message-container { display: flex; margin-bottom: 16px; } .message-container:last-child { margin-bottom: 32px; } .message-container .logo { -webkit-margin-end: 20px; background-size: cover; flex-shrink: 0; height: 20px; position: relative; top: -2px; width: 20px; } #chrome-logo { background-image: url(../../../../../ui/webui/resources/images/200-logo_chrome.png); } #googleg-logo { background-image: url(../../../../../ui/webui/resources/images/200-logo_googleg.png); } .message-container .title { font-weight: 500; margin-bottom: 4px; } .message-container .body { color: #646464; } .message-container .text { line-height: 20px; } .message-container #activityControlsCheckbox { -webkit-margin-start: 40px; } #undoButton { -webkit-margin-start: 8px; } #syncDisabledDetails { line-height: 20px; margin-bottom: 8px; margin-top: 16px; padding: 0 24px; } #illustration { height: 96px; margin: 0 auto; position: relative; width: 264px; } #checkmark-circle { background: rgb(66, 133, 244); border: 2px solid #fff; border-radius: 50%; bottom: 0; height: 24px; position: absolute; right: 0; transform: scale(0); width: 24px; } .loaded #checkmark-circle { animation: scale-circle 300ms cubic-bezier(0, 0, 0.2, 1) forwards; } @keyframes scale-circle { from { transform: scale(0); } to { transform: scale(1); } } #checkmark-check { left: 5px; position: absolute; top: 7px; } .loaded #checkmark-path { animation: draw-path 300ms cubic-bezier(0, 0, 0.2, 1) 100ms forwards; } @keyframes draw-path { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } } #icons { height: 96px; position: absolute; width: 264px; } #icons > div { animation-delay: 200ms; animation-duration: 1.4s; animation-fill-mode: forwards; animation-timing-function: cubic-bezier(0.25, 0.45, 0.4, 0.7); background-size: cover; opacity: 0; position: absolute; } #icon-bookmarks { background: url(../../../../../ui/webui/resources/images/icon_bookmarks.svg); height: 36px; left: 58px; top: 0; width: 36px; } #icon-extensions { background: url(../../../../../ui/webui/resources/images/icon_extensions.svg); height: 24px; left: 30px; top: 30px; width: 24px; } #icon-passwords { background: url(../../../../../ui/webui/resources/images/icon_passwords.svg); height: 30px; left: 38px; top: 66px; width: 40px; } #icon-history { background: url(../../../../../ui/webui/resources/images/icon_history.svg); height: 36px; left: 190px; top: 6px; width: 36px; } #icon-tabs { background: url(../../../../../ui/webui/resources/images/icon_tabs.svg); height: 24px; left: 222px; top: 44px; width: 24px; } #icon-themes { background: url(../../../../../ui/webui/resources/images/icon_themes.svg); height: 30px; left: 184px; top: 62px; width: 32px; } #icon-circle-open { border: 2px solid #000; border-radius: 50%; height: 8px; left: 6px; top: 56px; width: 8px; } .icon-circle { background: #000; border-radius: 50%; height: 4px; width: 4px; } #icon-circle-1 { left: 64px; top: 50px; } #icon-circle-2 { left: 178px; top: 18px; } #icon-circle-3 { left: 194px; top: 50px; } #icon-circle-4 { left: 258px; top: 36px; } .loaded .fade-top-left { animation-name: fade-in-icon-top-left; } .loaded .fade-top-right { animation-name: fade-in-icon-top-right; } .loaded .fade-middle-left { animation-name: fade-in-icon-middle-left; } .loaded .fade-middle-right { animation-name: fade-in-icon-middle-right; } .loaded .fade-bottom-left { animation-name: fade-in-icon-bottom-left; } .loaded .fade-bottom-right { animation-name: fade-in-icon-bottom-right; } @keyframes fade-in-icon-top-left { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(-4px, -4px); } } @keyframes fade-in-icon-top-right { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(4px, -4px); } } @keyframes fade-in-icon-middle-left { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(-4px, 0); } } @keyframes fade-in-icon-middle-right { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(4px, 0); } } @keyframes fade-in-icon-bottom-left { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(-4px, 4px); } } @keyframes fade-in-icon-bottom-right { from { opacity: 0; transform: translate(0, 0); } to { opacity: 0.1; transform: translate(4px, 4px); } }
$i18n{syncConfirmationTitle}
$i18n{syncConfirmationChromeSyncTitle}
$i18n{syncConfirmationChromeSyncBody}
$i18n{syncConfirmationPersonalizeServicesTitle}
$i18n{syncConfirmationPersonalizeServicesBody}
$i18nRaw{syncConfirmationSyncSettingsLinkBody}
$i18n{syncDisabledConfirmationDetails}
$i18n{syncConfirmationConfirmLabel} $i18n{syncConfirmationUndoLabel}
/* 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. */ cr.define('sync.confirmation', function() { 'use strict'; function onConfirm(e) { chrome.send('confirm'); } function onUndo(e) { chrome.send('undo'); } function onGoToSettings(e) { chrome.send('goToSettings'); } function initialize() { document.addEventListener('keydown', onKeyDown); $('confirmButton').addEventListener('click', onConfirm); $('undoButton').addEventListener('click', onUndo); if (loadTimeData.getBoolean('isSyncAllowed')) { $('settingsLink').addEventListener('click', onGoToSettings); $('profile-picture').addEventListener('load', onPictureLoaded); $('syncDisabledDetails').hidden = true; } else { $('syncConfirmationDetails').hidden = true; } // Prefer using |document.body.offsetHeight| instead of // |document.body.scrollHeight| as it returns the correct height of the // even when the page zoom in Chrome is different than 100%. chrome.send('initializedWithSize', [document.body.offsetHeight]); } function clearFocus() { document.activeElement.blur(); } function setUserImageURL(url) { if (loadTimeData.getBoolean('isSyncAllowed')) { $('profile-picture').src = url; } } function onPictureLoaded(e) { if (loadTimeData.getBoolean('isSyncAllowed')) { $('picture-container').classList.add('loaded'); } } function onKeyDown(e) { // If the currently focused element isn't something that performs an action // on "enter" being pressed and the user hits "enter", perform the default // action of the dialog, which is "OK, Got It". if (e.key == 'Enter' && !/^(A|PAPER-(BUTTON|CHECKBOX))$/.test(document.activeElement.tagName)) { $('confirmButton').click(); e.preventDefault(); } } // TODO(scottchen): clearFocus and setUserImageURL are called directly by the // C++ handler. C++ handlers should not be calling JS functions by name // anymore. They should be firing events with FireWebuiListener and have the // page itself decide whether to listen or not listen to the event. return { clearFocus: clearFocus, initialize: initialize, setUserImageURL: setUserImageURL }; }); document.addEventListener('DOMContentLoaded', sync.confirmation.initialize); /* Copyright 2017 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('sync.confirmation', function() { 'use strict'; function initialize() { const syncConfirmationBrowserProxy = sync.confirmation.SyncConfirmationBrowserProxyImpl.getInstance(); // Prefer using |document.body.offsetHeight| instead of // |document.body.scrollHeight| as it returns the correct height of the // even when the page zoom in Chrome is different than 100%. syncConfirmationBrowserProxy.initializedWithSize( [document.body.offsetHeight]); } function clearFocus() { document.activeElement.blur(); } // The C++ handler calls out to this Javascript function, so it needs to // exist in the namespace. However, this version of the sync confirmation // doesn't use a user image, so we do not need to actually implement this. // TODO(scottchen): make the C++ handler not call this at all. function setUserImageURL() {} return { clearFocus: clearFocus, initialize: initialize, setUserImageURL: setUserImageURL }; }); document.addEventListener('DOMContentLoaded', sync.confirmation.initialize); // Copyright 2017 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 helper object used by the sync confirmation dialog to * interact with the browser. */ cr.define('sync.confirmation', function() { /** @interface */ class SyncConfirmationBrowserProxy { confirm() {} undo() {} goToSettings() {} /** @param {!Array} height */ initializedWithSize(height) {} } /** @implements {sync.confirmation.SyncConfirmationBrowserProxy} */ class SyncConfirmationBrowserProxyImpl { /** @override */ confirm() { chrome.send('confirm'); } /** @override */ undo() { chrome.send('undo'); } /** @override */ goToSettings() { chrome.send('goToSettings'); } /** @override */ initializedWithSize(height) { chrome.send('initializedWithSize', height); } } cr.addSingletonGetter(SyncConfirmationBrowserProxyImpl); return { SyncConfirmationBrowserProxy: SyncConfirmationBrowserProxy, SyncConfirmationBrowserProxyImpl: SyncConfirmationBrowserProxyImpl, }; }); /* Copyright 2017 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: 'sync-confirmation-app', listeners: { // This is necessary since the settingsLink element is inserted by i18nRaw. 'settingsLink.tap': 'onGoToSettings_' }, /** @private {?sync.confirmation.SyncConfirmationBrowserProxy} */ syncConfirmationBrowserProxy_: null, /** @private {?function(Event)} */ boundKeyDownHandler_: null, /** @override */ attached: function() { this.syncConfirmationBrowserProxy_ = sync.confirmation.SyncConfirmationBrowserProxyImpl.getInstance(); this.boundKeyDownHandler_ = this.onKeyDown_.bind(this); // This needs to be bound to document instead of "this" because the dialog // window opens initially, the focus level is only on document, so the key // event is not captured by "this". document.addEventListener('keydown', this.boundKeyDownHandler_); }, /** @override */ detached: function() { document.removeEventListener('keydown', this.boundKeyDownHandler_); }, /** @private */ onConfirm_: function() { this.syncConfirmationBrowserProxy_.confirm(); }, /** @private */ onUndo_: function() { this.syncConfirmationBrowserProxy_.undo(); }, /** @private */ onGoToSettings_: function() { this.syncConfirmationBrowserProxy_.goToSettings(); }, /** @private */ onKeyDown_: function(e) { if (e.key == 'Enter' && !/^(A|PAPER-BUTTON)$/.test(e.path[0].tagName)) { this.onConfirm_(); e.preventDefault(); } }, });
$i18n{signinEmailConfirmationTitle}
$i18n{signinEmailConfirmationCreateProfileButtonTitle}
$i18n{signinEmailConfirmationCreateProfileButtonSubtitle}
$i18n{signinEmailConfirmationStartSyncButtonTitle}
$i18n{signinEmailConfirmationStartSyncButtonSubtitle}
$i18n{signinEmailConfirmationConfirmLabel} $i18n{signinEmailConfirmationCloseLabel}
/* Copyright 2016 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('signin.emailConfirmation', function() { 'use strict'; function initialize() { var args = JSON.parse(chrome.getVariableValue('dialogArguments')); var lastEmail = args.lastEmail; var newEmail = args.newEmail; $('dialogTitle').textContent = loadTimeData.getStringF('signinEmailConfirmationTitle', lastEmail); $('createNewUserRadioButtonSubtitle').textContent = loadTimeData.getStringF( 'signinEmailConfirmationCreateProfileButtonSubtitle', newEmail); $('startSyncRadioButtonSubtitle').textContent = loadTimeData.getStringF( 'signinEmailConfirmationStartSyncButtonSubtitle', newEmail); document.addEventListener('keydown', onKeyDown); $('confirmButton').addEventListener('click', onConfirm); $('closeButton').addEventListener('click', onCancel); } function onKeyDown(e) { // If the currently focused element isn't something that performs an action // on "enter" being pressed and the user hits "enter", perform the default // action of the dialog, which is "OK". if (e.key == 'Enter' && !/^(A|PAPER-BUTTON)$/.test(document.activeElement.tagName)) { $('confirmButton').click(); e.preventDefault(); } } function onConfirm(e) { var action; if ($('createNewUserRadioButton').active) { action = 'createNewUser'; } else if ($('startSyncRadioButton').active) { action = 'startSync'; } else { // Action is unknown as no radio button is selected. action = 'unknown'; } chrome.send('dialogClose', [JSON.stringify({'action': action})]); } function onCancel(e) { chrome.send('dialogClose', [JSON.stringify({'action': 'cancel'})]); } return { initialize: initialize, }; }); document.addEventListener( 'DOMContentLoaded', signin.emailConfirmation.initialize);
$i18n{signinErrorTitle}

$i18nRaw{signinErrorMessage}

$i18nRaw{signinErrorLearnMore}
$i18n{signinErrorSwitchLabel} $i18n{signinErrorCloseLabel}
/* Copyright 2016 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('signin.error', function() { 'use strict'; function initialize() { document.addEventListener('keydown', onKeyDown); $('confirmButton').addEventListener('click', onConfirm); $('closeButton').addEventListener('click', onConfirm); $('switchButton').addEventListener('click', onSwitchToExistingProfile); $('learnMoreLink').addEventListener('click', onLearnMore); if (loadTimeData.getBoolean('isSystemProfile')) { $('learnMoreLink').hidden = true; } // Prefer using |document.body.offsetHeight| instead of // |document.body.scrollHeight| as it returns the correct height of the // even when the page zoom in Chrome is different than 100%. chrome.send('initializedWithSize', [document.body.offsetHeight]); } function onKeyDown(e) { // If the currently focused element isn't something that performs an action // on "enter" being pressed and the user hits "enter", perform the default // action of the dialog, which is "OK". if (e.key == 'Enter' && !/^(A|PAPER-BUTTON)$/.test(document.activeElement.tagName)) { $('confirmButton').click(); e.preventDefault(); } } function onConfirm(e) { chrome.send('confirm'); } function onSwitchToExistingProfile(e) { chrome.send('switchToExistingProfile'); } function onLearnMore(e) { chrome.send('learnMore'); } function clearFocus() { document.activeElement.blur(); } function removeSwitchButton() { $('switchButton').hidden = true; $('closeButton').hidden = true; $('confirmButton').hidden = false; } return { initialize: initialize, clearFocus: clearFocus, removeSwitchButton: removeSwitchButton }; }); document.addEventListener('DOMContentLoaded', signin.error.initialize); N0DUQ)=$ĥ-vd;U"ĿRmw,3)?\X5\t@p$!;$QX!-ȦXT} $zhcp%% t'ht bP4GR(Pn[B*2s sP1%zOHpwsZHqı :Jm8Rb!O}y##9ukL /...I1΅Zky CRJuHʤXSZ( f+v̩Qj&dop 2="+?*#:TL2g"V~OP7z'1vYi Q5E%={[>aOSVzoy޷Ot`pdnvK$ (gePE\M2!HxEdW7*Q9Bu{S"e~{6?4mi({n 6[crcbFFxS;G[.:} |E͜ѯ52?Y< tF4HC:Un8+@ᡲ9cc/5PRk ENX߷I=F2]fDV^?jy[xhѝ|2 %Dk| V{S!TFוޠQXC.\-{RTZ J '5ګuO/,Io2bmPpiY:y4hH?T! |W\LQEdWZe9,RW-?{\ +JxZKI@8lmtֲD'(/Eφ,mpC Zcl3 Ӟ:^J|:EN0&(}ipZ c~Ɏ1k]cAZbx#f ۣ%x 'ow+Jd"U)T7X;j&\z稨iels, ;#jq1f~4x#G<zCКH-]9 y]6)Z6_yQ|p&{vI?ǽtGkT΂͹q'A3OՃ .{_ |N;v w% +#jqpG w?<D䬍(\/;(Ý+;zͼ$]ms6ss:Zv\"׽q^zLk{d( S=h%JSX]`_kfwJ7(3^P298I,A'Q2" ].!H&B p C@N+b8^( `LO@`3V$ ~=}0 #HGzd']0Aa@vX3I〄IϧO'_)wG(5ѿS<ƄGXe{ pWO>B{k#bp@ՎqBQi$Q\$:ypA炄s`]qvٗ[.G&iDxLGt&س8 B |h̟^Q8I ~]$21< ?B脚X )MSy*|ZL©$akb#Is:9G76.c/Xw+t[d1`9=5OZO YԠdSiCU { ,Δb9VibEF:nLgLHGg|qguOηG U9'YS.$AsF&Ju B.Ǭ4% nE sfPS =&\,0 $O,<:Hcr8Lޥ]gl;>.HE|~v~F e`CiƟűǟW{t/o[N~b\Am.hWlg&dQENsW't&4Bw0]6{F:jRe iA/KI:QM$1I|\}sJy .p|T4P1Y*Dqfq,QrCTtk"06F2J* URnWO妮鰝1i^wux Mg *6)W4K/_]آ좮Y]ʫ*+Tn/>x'٤<>_ȟk{ڃ5וv(?0Ī[n)/`K5_?^.1;u`1VƷKM`óƮ`vicf]5"\C.[I\]p;f,rH[µ%Eml9] _` Oilcmú@30S㧟@PK ΞEL"7mMxړYn1}6TS aIAڦ(<| Z)Vد}m^GW{{af?n!^آJ GmmnlVo~sZz +iZ7ycc/Vd%5 Ín=l[ۊELY(j›މG,%p)Ȭc+MtbזŔiRH݌7e4R=fjhh9|-ی$_ܲ~ᅯXxr]'Z{Wp?,o(C"p망YS76QhIK,>U/~̥b,873*M`w?3u&tA3>hcA./3H;JbrZe*VN' v TDQPj@ v6JȭxI DSE_x8 rl7 ȟ-leU~.ޛ`b#NzAxb8ȼ)$\I|4M0&JSyXUV|j=q%z`gxEK0?(ը_uB ]g@&{ ^ItEuG?^|Jg7 ^Z-(.ƫ^ |]PM̫ Raⰽ(=4}:yfB7xdz~9\f)ܕ)oִM&eӴoVL+uRУadw[SF [ |43N#) =?1!pM,Y$r=r"#r xo"~6]vc?(; bV%߷ھ3DXP:_hlC2l[G{MD&I҃D6w$9c)>/x4n3~zbΊQ| V%tM{]SU%VM͉UdӒRCDw4](xLZ|aJ.0dv渊׭kP?crBJ]86zyd ^Zu{(mQ*>Ǣ0U%*aDQ t=V?mNonCۀk,6G-. [qd?ޠVV=l+Vs+4޺6%mS]k0|غ/큭kg^R(RHdim)%#HJ)M) uJ:AAl{˾f$BqJc dAv6_+4ރG H5(ʻwy n!0ot+q䫡J=-''Ep}w7 ᘔ:a%|h` dPӣm@J(CA8_'ޢ>\_4y$-F)N V+AXGt|} 6†U{t4a.p=zW?".';x}ܦS@%m: ԱMNIx/]طw1HdͿykLeٱQ)ҁj;4=$xVhۄcKY7>eGu?p bYiSTxVwlk!3-"wU2Q`=,8XX,طjYGkW(3"ThWlͻ^:)ZAD齣%F9E29{t%2gh}IŠ2Fs&#bUyJqy~vӊjR3eG֊L* |x$Yf=: RѴ"0YŒZ2~~ UgFxD9 $i18n{webrtcLogsTitle}

$i18n{webrtcLogsTitle}

// 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. /** * Requests the list of uploads from the backend. */ function requestUploads() { chrome.send('requestWebRtcLogsList'); } /** * Callback from backend with the list of uploads. Builds the UI. * @param {array} uploads The list of uploads. * @param {string} version The browser version. */ function updateWebRtcLogsList(uploads, version) { $('log-banner').textContent = loadTimeData.getStringF('webrtcLogCountFormat', uploads.length); var logSection = $('log-list'); // Clear any previous list. logSection.textContent = ''; for (var i = 0; i < uploads.length; i++) { var upload = uploads[i]; var logBlock = document.createElement('div'); var title = document.createElement('h3'); title.textContent = loadTimeData.getStringF( 'webrtcLogHeaderFormat', upload['capture_time']); logBlock.appendChild(title); var localFileLine = document.createElement('p'); if (upload['local_file'].length == 0) { localFileLine.textContent = loadTimeData.getString('noLocalLogFileMessage'); } else { localFileLine.textContent = loadTimeData.getString('webrtcLogLocalFileLabelFormat') + ' '; var localFileLink = document.createElement('a'); localFileLink.href = 'file://' + upload['local_file']; localFileLink.textContent = upload['local_file']; localFileLine.appendChild(localFileLink); } logBlock.appendChild(localFileLine); var uploadLine = document.createElement('p'); if (upload['id'].length == 0) { uploadLine.textContent = loadTimeData.getString('webrtcLogNotUploadedMessage'); } else { uploadLine.textContent = loadTimeData.getStringF( 'webrtcLogUploadTimeFormat', upload['upload_time']) + '. ' + loadTimeData.getStringF('webrtcLogReportIdFormat', upload['id']) + '. '; var link = document.createElement('a'); var commentLines = [ 'Chrome Version: ' + version, // TODO(tbreisacher): fill in the OS automatically? 'Operating System: e.g., "Windows 7", "Mac OSX 10.6"', '', 'URL (if applicable) where the problem occurred:', '', 'Can you reproduce this problem?', '', 'What steps will reproduce this problem? (or if it\'s not ' + 'reproducible, what were you doing just before the problem)?', '', '1.', '2.', '3.', '', '*Please note that issues filed with no information filled in ' + 'above will be marked as WontFix*', '', '****DO NOT CHANGE BELOW THIS LINE****', 'report_id:' + upload.id ]; var params = { template: 'Defect report from user', comment: commentLines.join('\n'), }; var href = 'http://code.google.com/p/chromium/issues/entry'; for (var param in params) { href = appendParam(href, param, params[param]); } link.href = href; link.target = '_blank'; link.textContent = loadTimeData.getString('bugLinkText'); uploadLine.appendChild(link); } logBlock.appendChild(uploadLine); logSection.appendChild(logBlock); } $('no-logs').hidden = uploads.length != 0; } document.addEventListener('DOMContentLoaded', requestUploads); { // chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik/ "key": "MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQC4L17nAfeTd6Xhtx96WhQ6DSr8KdHeQmfzgCkieKLCgUkWdwB9G1DCuh0EPMDn1MdtSwUAT7xE36APEzi0X/UpKjOVyX8tCC3aQcLoRAE0aJAvCcGwK7qIaQaczHmHKvPC2lrRdzSoMMTC5esvHX+ZqIBMi123FOL0dGW6OPKzIwIBIw==", "name": "GaiaAuthExtension", "version": "0.0.1", "manifest_version": 2, "description": "GAIA Component Extension", "incognito": "split", "web_accessible_resources": [ "success.html" ] } // 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() { // Since all we want here is forwarding of certain commands, all can be done // in the anonymous function's scope. function wireUpWindow() { $('launch-button').addEventListener('click', function() { chrome.send('SetAsDefaultBrowser:LaunchSetDefaultBrowserFlow'); }); } window.addEventListener('DOMContentLoaded', wireUpWindow); })();

// Copyright 2016 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 'control-bar' is the horizontal bar at the bottom of the user * manager screen. */ Polymer({ is: 'control-bar', behaviors: [ I18nBehavior, ], properties: { /** * True if 'Browse as Guest' button is displayed. * @type {boolean} */ showGuest: {type: Boolean, value: false}, /** * True if 'Add Person' button is displayed. * @type {boolean} */ showAddPerson: {type: Boolean, value: false}, /** @private {!signin.ProfileBrowserProxy} */ browserProxy_: Object, /** * True if the force sign in policy is enabled. * @private {boolean} */ isForceSigninEnabled_: { type: Boolean, value: function() { return loadTimeData.getBoolean('isForceSigninEnabled'); }, } }, /** @override */ created: function() { this.browserProxy_ = signin.ProfileBrowserProxyImpl.getInstance(); }, /** * Handler for 'Browse as Guest' button click event. * @param {!Event} event * @private */ onLaunchGuestTap_: function(event) { this.browserProxy_.areAllProfilesLocked().then(allProfilesLocked => { if (!allProfilesLocked || this.isForceSigninEnabled_) { this.browserProxy_.launchGuestUser(); } else { document.querySelector('error-dialog') .show(this.i18n('browseAsGuestAllProfilesLockedError')); } }); }, /** * Handler for 'Add Person' button click event. * @param {!Event} event * @private */ onAddUserTap_: function(event) { this.browserProxy_.areAllProfilesLocked().then(allProfilesLocked => { if (!allProfilesLocked || this.isForceSigninEnabled_) { // Event is caught by user-manager-pages. this.fire('change-page', {page: 'create-user-page'}); } else { document.querySelector('error-dialog') .show(this.i18n('addProfileAllProfilesLockedError')); } }); } }); // Copyright 2016 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 'create-profile' is a page that contains controls for creating * a (optionally supervised) profile, including choosing a name, and an avatar. */ /** @typedef {{url: string, label:string}} */ var AvatarIcon; (function() { /** * Sentinel signed-in user's index value. * @const {number} */ var NO_USER_SELECTED = -1; Polymer({ is: 'create-profile', behaviors: [I18nBehavior, WebUIListenerBehavior], properties: { /** * The current profile name. * @private {string} */ profileName_: {type: String, value: ''}, /** * The list of available profile icon Urls and labels. * @private {!Array} */ availableIcons_: { type: Array, value: function() { return []; } }, /** * The currently selected profile avatar, if any. * @private {?AvatarIcon} */ selectedAvatar_: Object, /** * True if the existing supervised users are being loaded. * @private {boolean} */ loadingSupervisedUsers_: {type: Boolean, value: false}, /** * True if a profile is being created or imported. * @private {boolean} */ createInProgress_: {type: Boolean, value: false}, /** * True if the error/warning message is displaying. * @private {boolean} */ isMessageVisble_: {type: Boolean, value: false}, /** * The current error/warning message. * @private {string} */ message_: {type: String, value: ''}, /** * if true, a desktop shortcut will be created for the new profile. * @private {boolean} */ createShortcut_: {type: Boolean, value: true}, /** * True if the new profile is a supervised profile. * @private {boolean} */ isSupervised_: {type: Boolean, value: false}, /** * The list of usernames and profile paths for currently signed-in users. * @private {!Array} */ signedInUsers_: { type: Array, value: function() { return []; } }, /** * Index of the selected signed-in user. * @private {number} */ signedInUserIndex_: {type: Number, value: NO_USER_SELECTED}, /** @private {!signin.ProfileBrowserProxy} */ browserProxy_: Object, /** * True if the profile shortcuts feature is enabled. * @private */ isProfileShortcutsEnabled_: { type: Boolean, value: function() { return loadTimeData.getBoolean('profileShortcutsEnabled'); }, readOnly: true }, /** * True if the force sign in policy is enabled. * @private {boolean} */ isForceSigninEnabled_: { type: Boolean, value: function() { return loadTimeData.getBoolean('isForceSigninEnabled'); }, }, /** * True if Supervised User creation is enabled. * @private {boolean} */ isSupervisedUserCreationEnabled_: { type: Boolean, value: function() { return loadTimeData.getBoolean('isSupervisedUserCreationEnabled') && !loadTimeData.getBoolean('isForceSigninEnabled'); }, } }, listeners: {'tap': 'onTap_', 'importUserPopup.import': 'onImportUserPopupImport_'}, /** @override */ created: function() { this.browserProxy_ = signin.ProfileBrowserProxyImpl.getInstance(); }, /** @override */ ready: function() { this.addWebUIListener( 'create-profile-success', this.handleSuccess_.bind(this)); this.addWebUIListener( 'create-profile-warning', this.handleMessage_.bind(this)); this.addWebUIListener( 'create-profile-error', this.handleMessage_.bind(this)); this.addWebUIListener('profile-icons-received', icons => { this.availableIcons_ = icons; }); this.addWebUIListener( 'profile-defaults-received', this.handleProfileDefaults_.bind(this)); this.addWebUIListener( 'signedin-users-received', this.handleSignedInUsers_.bind(this)); this.browserProxy_.getAvailableIcons(); this.browserProxy_.getSignedInUsers(); }, /** @override */ attached: function() { this.$.nameInput.focus(); }, /** * Handles tap events from: * - links within dynamic warning/error messages pushed from the browser. * - the 'noSignedInUserMessage' i18n string. * @param {!Event} event * @private */ onTap_: function(event) { var element = Polymer.dom(event).rootTarget; if (element.id == 'supervised-user-import-existing') { this.onImportUserTap_(event); event.preventDefault(); } else if (element.id == 'sign-in-to-chrome') { this.browserProxy_.openUrlInLastActiveProfileBrowser(element.href); event.preventDefault(); } else if (element.id == 'reauth') { var elementData = /** @type {{userEmail: string}} */ (element.dataset); this.browserProxy_.authenticateCustodian(elementData.userEmail); this.hideMessage_(); event.preventDefault(); } }, /** * Handler for when the profile defaults are pushed from the browser. * @param {!ProfileInfo} profileInfo Default Info for the new profile. * @private */ handleProfileDefaults_: function(profileInfo) { this.profileName_ = profileInfo.name; }, /** * Handler for when signed-in users are pushed from the browser. * @param {!Array} signedInUsers * @private */ handleSignedInUsers_: function(signedInUsers) { this.signedInUsers_ = signedInUsers; }, /** * Returns the currently selected signed-in user. * @return {(!SignedInUser|undefined)} * @private */ signedInUser_: function(signedInUserIndex) { return this.signedInUsers_[signedInUserIndex]; }, /** * Handler for the 'Learn More' link tap event. * @param {!Event} event * @private */ onLearnMoreTap_: function(event) { this.fire('change-page', {page: 'supervised-learn-more-page'}); }, /** * Handler for the 'Import Supervised User' link tap event. * @param {!Event} event * @private */ onImportUserTap_: function(event) { if (this.signedInUserIndex_ == NO_USER_SELECTED) { // A custodian must be selected. this.handleMessage_( this.i18nAdvanced('custodianAccountNotSelectedError')); } else { var signedInUser = this.signedInUser_(this.signedInUserIndex_); this.hideMessage_(); this.loadingSupervisedUsers_ = true; this.browserProxy_.getExistingSupervisedUsers(signedInUser.profilePath) .then( this.showImportSupervisedUserPopup_.bind(this), this.handleMessage_.bind(this)); } }, /** * Handler for the 'Save' button tap event. * @param {!Event} event * @private */ onSaveTap_: function(event) { if (!this.isSupervised_) { // The new profile is not supervised. Go ahead and create it. this.createProfile_(); } else if (this.signedInUserIndex_ == NO_USER_SELECTED) { // If the new profile is supervised, a custodian must be selected. this.handleMessage_( this.i18nAdvanced('custodianAccountNotSelectedError')); } else { var signedInUser = this.signedInUser_(this.signedInUserIndex_); this.hideMessage_(); this.loadingSupervisedUsers_ = true; this.browserProxy_.getExistingSupervisedUsers(signedInUser.profilePath) .then( this.createProfileIfValidSupervisedUser_.bind(this), this.handleMessage_.bind(this)); } }, /** * Displays the import supervised user popup or an error message if there are * no existing supervised users. * @param {!Array} supervisedUsers The list of existing * supervised users. * @private */ showImportSupervisedUserPopup_: function(supervisedUsers) { this.loadingSupervisedUsers_ = false; if (supervisedUsers.length > 0) { this.$.importUserPopup.show( this.signedInUser_(this.signedInUserIndex_), supervisedUsers); } else { this.handleMessage_(this.i18nAdvanced('noSupervisedUserImportText')); } }, /** * Checks if the entered name matches name of an existing supervised user. * If yes, the user is prompted to import the existing supervised user. * If no, the new supervised profile gets created. * @param {!Array} supervisedUsers The list of existing * supervised users. * @private */ createProfileIfValidSupervisedUser_: function(supervisedUsers) { for (var i = 0; i < supervisedUsers.length; ++i) { if (supervisedUsers[i].name != this.profileName_) continue; // Check if another supervised user also exists with that name. var nameIsUnique = true; // Handling the case when multiple supervised users with the same // name exist, but not all of them are on the device. // If at least one is not imported, we want to offer that // option to the user. This could happen due to a bug that allowed // creating SUs with the same name (https://crbug.com/557445). var allOnCurrentDevice = supervisedUsers[i].onCurrentDevice; for (var j = i + 1; j < supervisedUsers.length; ++j) { if (supervisedUsers[j].name == this.profileName_) { nameIsUnique = false; allOnCurrentDevice = allOnCurrentDevice && supervisedUsers[j].onCurrentDevice; } } var opts = { 'substitutions': [HTMLEscape(elide(this.profileName_, /* maxLength */ 50))], 'attrs': { 'id': function(node, value) { return node.tagName == 'A'; }, 'is': function(node, value) { return node.tagName == 'A' && value == 'action-link'; }, 'role': function(node, value) { return node.tagName == 'A' && value == 'link'; }, 'tabindex': function(node, value) { return node.tagName == 'A'; } } }; this.handleMessage_( allOnCurrentDevice ? this.i18nAdvanced('managedProfilesExistingLocalSupervisedUser') : this.i18nAdvanced('manageProfilesExistingSupervisedUser', opts)); return; } // No existing supervised user's name matches the entered profile name. // Continue with creating the new supervised profile. this.createProfile_(); // Set this to false after createInProgress_ has been set to true in // order for the 'Save' button to remain disabled. this.loadingSupervisedUsers_ = false; }, /** * Creates the new profile. * @private */ createProfile_: function() { var custodianProfilePath = ''; if (this.signedInUserIndex_ != NO_USER_SELECTED) { custodianProfilePath = this.signedInUser_(this.signedInUserIndex_).profilePath; } this.hideMessage_(); this.createInProgress_ = true; var createShortcut = this.isProfileShortcutsEnabled_ && this.createShortcut_; // Select the 1st avatar if none selected. var selectedAvatar = this.selectedAvatar_ || this.availableIcons_[0]; this.browserProxy_.createProfile( this.profileName_, selectedAvatar.url, createShortcut, this.isSupervised_, '', custodianProfilePath); }, /** * Handler for a change in the supervised account dropdown. * @param {!{target: HTMLSelectElement}} event * @private */ onAccountChanged_: function(event) { this.signedInUserIndex_ = parseInt(event.target.value, 10); }, /** * Handler for the 'import' event fired by #importUserPopup once a supervised * user is selected to be imported and the popup closes. * @param {!{detail: {supervisedUser: !SupervisedUser, * signedInUser: !SignedInUser}}} event * @private */ onImportUserPopupImport_: function(event) { var supervisedUser = event.detail.supervisedUser; var signedInUser = event.detail.signedInUser; this.hideMessage_(); this.createInProgress_ = true; var createShortcut = this.isProfileShortcutsEnabled_; this.browserProxy_.createProfile( supervisedUser.name, supervisedUser.iconURL, createShortcut, true /* isSupervised */, supervisedUser.id, signedInUser.profilePath); }, /** * Handler for the 'Cancel' button tap event. * @param {!Event} event * @private */ onCancelTap_: function(event) { if (this.createInProgress_) { this.createInProgress_ = false; this.browserProxy_.cancelCreateProfile(); } else if (this.loadingSupervisedUsers_) { this.loadingSupervisedUsers_ = false; this.browserProxy_.cancelLoadingSupervisedUsers(); } else { this.fire('change-page', {page: 'user-pods-page'}); } }, /** * Handles profile create/import success message pushed by the browser. * @param {!ProfileInfo} profileInfo Details of the created/imported profile. * @private */ handleSuccess_: function(profileInfo) { this.createInProgress_ = false; if (profileInfo.showConfirmation) { this.fire( 'change-page', {page: 'supervised-create-confirm-page', data: profileInfo}); } else { this.fire('change-page', {page: 'user-pods-page'}); } }, /** * Hides the warning/error message. * @private */ hideMessage_: function() { this.isMessageVisble_ = false; }, /** * Handles warning/error messages when a profile is being created/imported * or the existing supervised users are being loaded. * @param {*} message An HTML warning/error message. * @private */ handleMessage_: function(message) { this.createInProgress_ = false; this.loadingSupervisedUsers_ = false; this.message_ = '' + message; this.isMessageVisble_ = true; }, /** * Returns a translated message that contains link elements with the 'id' * attribute. * @param {string} id The ID of the string to translate. * @private */ i18nAllowIDAttr_: function(id) { var opts = { 'attrs': { 'id': function(node, value) { return node.tagName == 'A'; } } }; return this.i18nAdvanced(id, opts); }, /** * Computed binding determining whether the paper-spinner is active. * @param {boolean} createInProgress Is create in progress? * @param {boolean} loadingSupervisedUsers Are supervised users being loaded? * @return {boolean} * @private */ isSpinnerActive_: function(createInProgress, loadingSupervisedUsers) { return createInProgress || loadingSupervisedUsers; }, /** * Computed binding determining whether 'Save' button is disabled. * @param {boolean} createInProgress Is create in progress? * @param {boolean} loadingSupervisedUsers Are supervised users being loaded? * @param {string} profileName Profile Name. * @return {boolean} * @private */ isSaveDisabled_: function( createInProgress, loadingSupervisedUsers, profileName) { // TODO(mahmadi): Figure out a way to add 'paper-input-extracted' as a // dependency and cast to PaperInputElement instead. /** @type {{validate: function():boolean}} */ var nameInput = this.$.nameInput; return createInProgress || loadingSupervisedUsers || !profileName || !nameInput.validate(); }, /** * Returns True if the import existing supervised user link should be hidden. * @param {boolean} createInProgress True if create/import is in progress. * @param {boolean} loadingSupervisedUsers True if supervised users are being * loaded. * @param {number} signedInUserIndex Index of the selected signed-in user. * @return {boolean} * @private */ isImportUserLinkHidden_: function( createInProgress, loadingSupervisedUsers, signedInUserIndex) { return createInProgress || loadingSupervisedUsers || !this.signedInUser_(signedInUserIndex); }, /** * Computed binding that returns True if there are any signed-in users. * @param {!Array} signedInUsers signed-in users. * @return {boolean} * @private */ isSignedIn_: function(signedInUsers) { return signedInUsers.length > 0; } }); }()); // Copyright 2016 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 'error-dialog' is a dialog that displays error messages * in the user manager. */ (function() { Polymer({ is: 'error-dialog', properties: { /** * The message shown in the dialog. * @private {string} */ message_: {type: String, value: ''} }, /** * Displays the dialog populated with the given message. * @param {string} message Error message to show. */ show: function(message) { this.message_ = message; this.$.dialog.showModal(); } }); })(); // Copyright 2016 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 'import-supervised-user' is a dialog that allows user to select * a supervised profile from a list of profiles to import on the current device. */ (function() { /** * It means no supervised user is selected. * @const {number} */ var NO_USER_SELECTED = -1; Polymer({ is: 'import-supervised-user', behaviors: [ I18nBehavior, ], properties: { /** * The currently signed in user and the custodian. * @private {?SignedInUser} */ signedInUser_: { type: Object, value: function() { return null; } }, /** * The list of supervised users managed by signedInUser_. * @private {!Array} */ supervisedUsers_: { type: Array, value: function() { return []; } }, /** * Index of the selected supervised user. * @private {number} */ supervisedUserIndex_: {type: Number, value: NO_USER_SELECTED} }, /** * Displays the dialog. * @param {(!SignedInUser|undefined)} signedInUser * @param {!Array} supervisedUsers */ show: function(signedInUser, supervisedUsers) { this.supervisedUsers_ = supervisedUsers; this.supervisedUsers_.sort(function(a, b) { if (a.onCurrentDevice != b.onCurrentDevice) return a.onCurrentDevice ? 1 : -1; return a.name.localeCompare(b.name); }); this.supervisedUserIndex_ = NO_USER_SELECTED; this.signedInUser_ = signedInUser || null; if (this.signedInUser_) this.$.dialog.showModal(); }, /** * @param {number} supervisedUserIndex Index of the selected supervised user. * @return {boolean} Whether the 'Import' button should be disabled. * @private */ isImportDisabled_: function(supervisedUserIndex) { return supervisedUserIndex == NO_USER_SELECTED; }, /** * Called when the user clicks the 'Import' button. it proceeds with importing * the supervised user. * @private */ onImportTap_: function() { var supervisedUser = this.supervisedUsers_[this.supervisedUserIndex_]; if (this.signedInUser_ && supervisedUser) { this.$.dialog.close(); // Event is caught by create-profile. this.fire( 'import', {supervisedUser: supervisedUser, signedInUser: this.signedInUser_}); } }, /** @private */ onCancelTap_: function() { this.$.dialog.close(); }, }); })(); // Copyright 2016 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 Helper object and related behavior that encapsulate messaging * between JS and C++ for creating/importing profiles in the user-manager page. */ /** @typedef {{username: string, profilePath: string}} */ var SignedInUser; /** * @typedef {{name: string, * filePath: string, * isSupervised: boolean, * custodianUsername: string, * showConfirmation: boolean}} */ var ProfileInfo; /** * @typedef {{id: string, * name: string, * iconURL: string, * onCurrentDevice: boolean}} */ var SupervisedUser; cr.define('signin', function() { /** @interface */ function ProfileBrowserProxy() {} ProfileBrowserProxy.prototype = { /** * Gets the available profile icons to choose from. */ getAvailableIcons: function() { assertNotReached(); }, /** * Gets the current signed-in users. */ getSignedInUsers: function() { assertNotReached(); }, /** * Launches the guest user. */ launchGuestUser: function() { assertNotReached(); }, /** * @param {string} profilePath Profile Path of the custodian. * @return {!Promise>} The list of existing * supervised users. */ getExistingSupervisedUsers: function(profilePath) { assertNotReached(); }, /** * Creates a profile. * @param {string} profileName Name of the new profile. * @param {string} profileIconUrl URL of the selected icon of the new * profile. * @param {boolean} createShortcut if true a desktop shortcut will be * created. * @param {boolean} isSupervised True if the new profile is supervised. * @param {string} supervisedUserId ID of the supervised user to be * imported. * @param {string} custodianProfilePath Profile path of the custodian if * the new profile is supervised. */ createProfile: function( profileName, profileIconUrl, createShortcut, isSupervised, supervisedUserId, custodianProfilePath) { assertNotReached(); }, /** * Cancels creation of the new profile. */ cancelCreateProfile: function() { assertNotReached(); }, /** * Cancels loading supervised users. */ cancelLoadingSupervisedUsers: function() { assertNotReached(); }, /** * Initializes the UserManager * @param {string} locationHash */ initializeUserManager: function(locationHash) { assertNotReached(); }, /** * Launches the user with the given |profilePath| * @param {string} profilePath Profile Path of the user. */ launchUser: function(profilePath) { assertNotReached(); }, /** * Opens the given url in a new tab in the browser instance of the last * active profile. Hyperlinks don't work in the user manager since its * browser instance does not support tabs. * @param {string} url */ openUrlInLastActiveProfileBrowser: function(url) { assertNotReached(); }, /** * Switches to the profile with the given path. * @param {string} profilePath Path to the profile to switch to. */ switchToProfile: function(profilePath) { assertNotReached(); }, /** * @return {!Promise} Whether all (non-supervised and non-child) * profiles are locked. */ areAllProfilesLocked: function() { assertNotReached(); }, /** * Authenticates the custodian profile with the given email address. * @param {string} emailAddress Email address of the custodian profile. */ authenticateCustodian: function(emailAddress) { assertNotReached(); } }; /** * @constructor * @implements {signin.ProfileBrowserProxy} */ function ProfileBrowserProxyImpl() {} // The singleton instance_ is replaced with a test version of this wrapper // during testing. cr.addSingletonGetter(ProfileBrowserProxyImpl); ProfileBrowserProxyImpl.prototype = { /** @override */ getAvailableIcons: function() { chrome.send('requestDefaultProfileIcons'); }, /** @override */ getSignedInUsers: function() { chrome.send('requestSignedInProfiles'); }, /** @override */ launchGuestUser: function() { chrome.send('launchGuest'); }, /** @override */ getExistingSupervisedUsers: function(profilePath) { return cr.sendWithPromise('getExistingSupervisedUsers', profilePath); }, /** @override */ createProfile: function( profileName, profileIconUrl, createShortcut, isSupervised, supervisedUserId, custodianProfilePath) { chrome.send('createProfile', [ profileName, profileIconUrl, createShortcut, isSupervised, supervisedUserId, custodianProfilePath ]); }, /** @override */ cancelCreateProfile: function() { chrome.send('cancelCreateProfile'); }, /** @override */ cancelLoadingSupervisedUsers: function() { chrome.send('cancelLoadingSupervisedUsers'); }, /** @override */ initializeUserManager: function(locationHash) { chrome.send('userManagerInitialize', [locationHash]); }, /** @override */ launchUser: function(profilePath) { chrome.send('launchUser', [profilePath]); }, /** @override */ openUrlInLastActiveProfileBrowser: function(url) { chrome.send('openUrlInLastActiveProfileBrowser', [url]); }, /** @override */ switchToProfile: function(profilePath) { chrome.send('switchToProfile', [profilePath]); }, /** @override */ areAllProfilesLocked: function() { return cr.sendWithPromise('areAllProfilesLocked'); }, /** @override */ authenticateCustodian: function(emailAddress) { chrome.send('authenticateCustodian', [emailAddress]); } }; return { ProfileBrowserProxy: ProfileBrowserProxy, ProfileBrowserProxyImpl: ProfileBrowserProxyImpl, }; }); // Copyright 2016 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 'supervised-user-create-confirm' is a page that is displayed * upon successful creation of a supervised user. It contains information for * the custodian on where to configure browsing restrictions as well as how to * exit and childlock their profile. */ (function() { /** * Maximum length of the supervised user profile name or custodian's username. * @const {number} */ var MAX_NAME_LENGTH = 50; Polymer({ is: 'supervised-user-create-confirm', behaviors: [I18nBehavior], properties: { /** * Profile Info of the supervised user that is passed to the page. * @type {?ProfileInfo} */ profileInfo: { type: Object, value: function() { return null; } }, /** @private {!signin.ProfileBrowserProxy} */ browserProxy_: Object }, listeners: {'tap': 'onTap_'}, /** @override */ created: function() { this.browserProxy_ = signin.ProfileBrowserProxyImpl.getInstance(); }, /** * Handles tap events from dynamically created links in the * supervisedUserCreatedText i18n string. * @param {!Event} event * @private */ onTap_: function(event) { var element = Polymer.dom(event).rootTarget; // Handle the tap event only if the target is a '' element. if (element.nodeName == 'A') { this.browserProxy_.openUrlInLastActiveProfileBrowser(element.href); event.preventDefault(); } }, /** * Returns the shortened profile name or empty string if |profileInfo| is * null. * @param {?ProfileInfo} profileInfo * @return {string} * @private */ elideProfileName_: function(profileInfo) { var name = profileInfo ? profileInfo.name : ''; return elide(name, MAX_NAME_LENGTH); }, /** * Returns the shortened custodian username or empty string if |profileInfo| * is null. * @param {?ProfileInfo} profileInfo * @return {string} * @private */ elideCustodianUsername_: function(profileInfo) { var name = profileInfo ? profileInfo.custodianUsername : ''; return elide(name, MAX_NAME_LENGTH); }, /** * Computed binding returning the text of the title section. * @param {?ProfileInfo} profileInfo * @return {string} * @private */ titleText_: function(profileInfo) { return this.i18n( 'supervisedUserCreatedTitle', this.elideProfileName_(profileInfo)); }, /** * Computed binding returning the sanitized confirmation HTML message that is * safe to set as innerHTML. * @param {?ProfileInfo} profileInfo * @return {string} * @private */ confirmationMessage_: function(profileInfo) { return this.i18nAdvanced('supervisedUserCreatedText', { substitutions: [ this.elideProfileName_(profileInfo), this.elideCustodianUsername_(profileInfo) ], }); }, /** * Computed binding returning the text of the 'Switch To User' button. * @param {?ProfileInfo} profileInfo * @return {string} * @private */ switchUserText_: function(profileInfo) { return this.i18n( 'supervisedUserCreatedSwitch', this.elideProfileName_(profileInfo)); }, /** * Handler for the 'Ok' button tap event. * @param {!Event} event * @private */ onOkTap_: function(event) { // Event is caught by user-manager-pages. this.fire('change-page', {page: 'user-pods-page'}); }, /** * Handler for the 'Switch To User' button tap event. * @param {!Event} event * @private */ onSwitchUserTap_: function(event) { this.browserProxy_.switchToProfile(this.profileInfo.filePath); // Event is caught by user-manager-pages. this.fire('change-page', {page: 'user-pods-page'}); } }); })(); // Copyright 2016 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 'supervised-user-learn-more' is a page that contains * information about what a supervised user is, what happens when a supervised * user is created, and a link to the help center for more information. */ Polymer({ is: 'supervised-user-learn-more', properties: { /** @private {!signin.ProfileBrowserProxy} */ browserProxy_: Object }, listeners: {'tap': 'onTap_'}, /** @override */ created: function() { this.browserProxy_ = signin.ProfileBrowserProxyImpl.getInstance(); }, /** * Handles tap events from dynamically created links in the * supervisedUserLearnMoreText i18n string. * @param {!Event} event * @private */ onTap_: function(event) { var element = Polymer.dom(event).rootTarget; // Handle the tap event only if the target is a '' element. if (element.nodeName == 'A') { this.browserProxy_.openUrlInLastActiveProfileBrowser(element.href); event.preventDefault(); } }, /** * Handler for the 'Done' button tap event. * @param {!Event} event * @private */ onDoneTap_: function(event) { // Event is caught by user-manager-pages. this.fire('change-page', {page: 'create-user-page'}); } }); $i18n{title}
$i18n{userManagerPromptMessage}
// Copyright 2016 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 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 Base class for all login WebUI screens. */ cr.define('login', function() { /** @const */ var CALLBACK_CONTEXT_CHANGED = 'contextChanged'; /** @const */ var CALLBACK_USER_ACTED = 'userActed'; function doNothing() {}; function alwaysTruePredicate() { return true; } var querySelectorAll = HTMLDivElement.prototype.querySelectorAll; var Screen = function(sendPrefix) { this.sendPrefix_ = sendPrefix; this.screenContext_ = null; this.contextObservers_ = {}; }; Screen.prototype = { __proto__: HTMLDivElement.prototype, /** * Prefix added to sent to Chrome messages' names. */ sendPrefix_: null, /** * Context used by this screen. */ screenContext_: null, get context() { return this.screenContext_; }, /** * Dictionary of context observers that are methods of |this| bound to * |this|. */ contextObservers_: null, /** * Called during screen initialization. */ decorate: doNothing, /** * Returns minimal size that screen prefers to have. Default implementation * returns current screen size. * @return {{width: number, height: number}} */ getPreferredSize: function() { return {width: this.offsetWidth, height: this.offsetHeight}; }, /** * Called for currently active screen when screen size changed. */ onWindowResize: doNothing, /** * @final */ initialize: function() { return this.initializeImpl_.apply(this, arguments); }, /** * @final */ send: function() { return this.sendImpl_.apply(this, arguments); }, /** * @final */ addContextObserver: function() { return this.addContextObserverImpl_.apply(this, arguments); }, /** * @final */ removeContextObserver: function() { return this.removeContextObserverImpl_.apply(this, arguments); }, /** * @final */ commitContextChanges: function() { return this.commitContextChangesImpl_.apply(this, arguments); }, /** * Creates and returns new button element with given identifier * and on-click event listener, which sends notification about * user action to the C++ side. * * @param {string} id Identifier of a button. * @param {string} opt_action_id Identifier of user action. * @final */ declareButton: function(id, opt_action_id) { var button = this.ownerDocument.createElement('button'); button.id = id; this.declareUserAction(button, { action_id: opt_action_id, event: 'click' }); return button; }, /** * Adds event listener to an element which sends notification * about event to the C++ side. * * @param {Element} element An DOM element * @param {Object} options A dictionary of optional arguments: * {string} event: name of event that will be listened, * default: 'click'. * {string} action_id: name of an action which will be sent to * the C++ side. * {function} condition: a one-argument function which takes * event as an argument, notification is sent to the * C++ side iff condition is true, default: constant * true function. * @final */ declareUserAction: function(element, options) { var self = this; options = options || {}; var event = options.event || 'click'; var action_id = options.action_id || element.id; var condition = options.condition || alwaysTruePredicate; element.addEventListener(event, function(e) { if (condition(e)) self.sendImpl_(CALLBACK_USER_ACTED, action_id); e.stopPropagation(); }); }, /** * @override * @final */ querySelectorAll: function() { return this.querySelectorAllImpl_.apply(this, arguments); }, /** * Does the following things: * * Creates screen context. * * Looks for elements having "alias" property and adds them as the * proprties of the screen with name equal to value of "alias", i.e. HTML * element
will be stored in this.myDiv. * * Looks for buttons having "action" properties and adds click handlers * to them. These handlers send |CALLBACK_USER_ACTED| messages to * C++ with "action" property's value as payload. * @private */ initializeImpl_: function() { if (cr.isChromeOS) this.screenContext_ = new login.ScreenContext(); this.decorate(); this.querySelectorAllImpl_('[alias]').forEach(function(element) { var alias = element.getAttribute('alias'); if (alias in this) throw Error('Alias "' + alias + '" of "' + this.name() + '" screen ' + 'shadows or redefines property that is already defined.'); this[alias] = element; this[element.getAttribute('alias')] = element; }, this); var self = this; this.querySelectorAllImpl_('button[action]').forEach(function(button) { button.addEventListener('click', function(e) { var action = this.getAttribute('action'); self.send(CALLBACK_USER_ACTED, action); e.stopPropagation(); }); }); }, /** * Sends message to Chrome, adding needed prefix to message name. All * arguments after |messageName| are packed into message parameters list. * * @param {string} messageName Name of message without a prefix. * @param {...*} varArgs parameters for message. * @private */ sendImpl_: function(messageName, varArgs) { if (arguments.length == 0) throw Error('Message name is not provided.'); var fullMessageName = this.sendPrefix_ + messageName; var payload = Array.prototype.slice.call(arguments, 1); chrome.send(fullMessageName, payload); }, /** * Starts observation of property with |key| of the context attached to * current screen. This method differs from "login.ScreenContext" in that * it automatically detects if observer is method of |this| and make * all needed actions to make it work correctly. So it's no need for client * to bind methods to |this| and keep resulting callback for * |removeObserver| call: * * this.addContextObserver('key', this.onKeyChanged_); * ... * this.removeContextObserver('key', this.onKeyChanged_); * @private */ addContextObserverImpl_: function(key, observer) { var realObserver = observer; var propertyName = this.getPropertyNameOf_(observer); if (propertyName) { if (!this.contextObservers_.hasOwnProperty(propertyName)) this.contextObservers_[propertyName] = observer.bind(this); realObserver = this.contextObservers_[propertyName]; } this.screenContext_.addObserver(key, realObserver); }, /** * Removes |observer| from the list of context observers. Supports not only * regular functions but also screen methods (see comment to * |addContextObserver|). * @private */ removeContextObserverImpl_: function(observer) { var realObserver = observer; var propertyName = this.getPropertyNameOf_(observer); if (propertyName) { if (!this.contextObservers_.hasOwnProperty(propertyName)) return; realObserver = this.contextObservers_[propertyName]; delete this.contextObservers_[propertyName]; } this.screenContext_.removeObserver(realObserver); }, /** * Sends recent context changes to C++ handler. * @private */ commitContextChangesImpl_: function() { if (!this.screenContext_.hasChanges()) return; this.sendImpl_(CALLBACK_CONTEXT_CHANGED, this.screenContext_.getChangesAndReset()); }, /** * Calls standart |querySelectorAll| method and returns its result converted * to Array. * @private */ querySelectorAllImpl_: function(selector) { var list = querySelectorAll.call(this, selector); return Array.prototype.slice.call(list); }, /** * Called when context changes are recieved from C++. * @private */ contextChanged_: function(diff) { this.screenContext_.applyChanges(diff); }, /** * If |value| is the value of some property of |this| returns property's * name. Otherwise returns empty string. * @private */ getPropertyNameOf_: function(value) { for (var key in this) if (this[key] === value) return key; return ''; } }; Screen.CALLBACK_USER_ACTED = CALLBACK_USER_ACTED; return { Screen: Screen }; }); cr.define('login', function() { return { /** * Creates class and object for screen. * Methods specified in EXTERNAL_API array of prototype * will be available from C++ part. * Example: * login.createScreen('ScreenName', 'screen-id', { * foo: function() { console.log('foo'); }, * bar: function() { console.log('bar'); } * EXTERNAL_API: ['foo']; * }); * login.ScreenName.register(); * var screen = $('screen-id'); * screen.foo(); // valid * login.ScreenName.foo(); // valid * screen.bar(); // valid * login.ScreenName.bar(); // invalid * * @param {string} name Name of created class. * @param {string} id Id of div representing screen. * @param {(function()|Object)} proto Prototype of object or function that * returns prototype. */ createScreen: function(name, id, template) { if (typeof template == 'function') template = template(); var apiNames = template.EXTERNAL_API || []; for (var i = 0; i < apiNames.length; ++i) { var methodName = apiNames[i]; if (typeof template[methodName] !== 'function') throw Error('External method "' + methodName + '" for screen "' + name + '" not a function or undefined.'); } function checkPropertyAllowed(propertyName) { if (propertyName.charAt(propertyName.length - 1) === '_' && (propertyName in login.Screen.prototype)) { throw Error('Property "' + propertyName + '" of "' + id + '" ' + 'shadows private property of login.Screen prototype.'); } }; var Constructor = function() { login.Screen.call(this, 'login.' + name + '.'); }; Constructor.prototype = Object.create(login.Screen.prototype); var api = {}; Object.getOwnPropertyNames(template).forEach(function(propertyName) { if (propertyName === 'EXTERNAL_API') return; checkPropertyAllowed(propertyName); var descriptor = Object.getOwnPropertyDescriptor(template, propertyName); Object.defineProperty(Constructor.prototype, propertyName, descriptor); if (apiNames.indexOf(propertyName) >= 0) { api[propertyName] = function() { var screen = $(id); return screen[propertyName].apply(screen, arguments); }; } }); Constructor.prototype.name = function() { return id; }; api.contextChanged = function() { var screen = $(id); screen.contextChanged_.apply(screen, arguments); } api.register = function(opt_lazy_init) { var screen = $(id); screen.__proto__ = new Constructor(); if (opt_lazy_init !== undefined && opt_lazy_init) screen.deferredInitialization = function() { screen.initialize(); } else screen.initialize(); Oobe.getInstance().registerScreen(screen); }; cr.define('login', function() { var result = {}; result[name] = api; return result; }); } }; }); // // 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 Bubble implementation. */ // TODO(xiyuan): Move this into shared. cr.define('cr.ui', function() { /** * Creates a bubble div. * @constructor * @extends {HTMLDivElement} */ var Bubble = cr.ui.define('div'); /** * Bubble key codes. * @enum {number} */ var Keys = { TAB: 'Tab', ENTER: 'Enter', ESC: 'Escape', SPACE: ' ' }; /** * Bubble attachment side. * @enum {number} */ Bubble.Attachment = { RIGHT: 0, LEFT: 1, TOP: 2, BOTTOM: 3 }; Bubble.prototype = { __proto__: HTMLDivElement.prototype, // Anchor element for this bubble. anchor_: undefined, // If defined, sets focus to this element once bubble is closed. Focus is // set to this element only if there's no any other focused element. elementToFocusOnHide_: undefined, // With help of these elements we create closed artificial tab-cycle through // bubble elements. firstBubbleElement_: undefined, lastBubbleElement_: undefined, // Whether to hide bubble when key is pressed. hideOnKeyPress_: true, /** @override */ decorate: function() { this.docKeyDownHandler_ = this.handleDocKeyDown_.bind(this); this.selfClickHandler_ = this.handleSelfClick_.bind(this); this.ownerDocument.addEventListener('click', this.handleDocClick_.bind(this)); // Set useCapture to true because scroll event does not bubble. this.ownerDocument.addEventListener('scroll', this.handleScroll_.bind(this), true); this.ownerDocument.addEventListener('keydown', this.docKeyDownHandler_); window.addEventListener('blur', this.handleWindowBlur_.bind(this)); this.addEventListener('transitionend', this.handleTransitionEnd_.bind(this)); // Guard timer for 200ms + epsilon. ensureTransitionEndEvent(this, 250); }, /** * Element that should be focused on hide. * @type {HTMLElement} */ set elementToFocusOnHide(value) { this.elementToFocusOnHide_ = value; }, /** * Element that should be focused on shift-tab of first bubble element * to create artificial closed tab-cycle through bubble. * Usually close-button. * @type {HTMLElement} */ set lastBubbleElement(value) { this.lastBubbleElement_ = value; }, /** * Element that should be focused on tab of last bubble element * to create artificial closed tab-cycle through bubble. * Same element as first focused on bubble opening. * @type {HTMLElement} */ set firstBubbleElement(value) { this.firstBubbleElement_ = value; }, /** * Whether to hide bubble when key is pressed. * @type {boolean} */ set hideOnKeyPress(value) { this.hideOnKeyPress_ = value; }, /** * Whether to hide bubble when clicked inside bubble element. * Default is true. * @type {boolean} */ set hideOnSelfClick(value) { if (value) this.removeEventListener('click', this.selfClickHandler_); else this.addEventListener('click', this.selfClickHandler_); }, /** * Handler for click event which prevents bubble auto hide. * @private */ handleSelfClick_: function(e) { // Allow clicking on [x] button. if (e.target && e.target.classList.contains('close-button')) return; e.stopPropagation(); }, /** * Sets the attachment of the bubble. * @param {!Attachment} attachment Bubble attachment. */ setAttachment_: function(attachment) { var styleClassList = ['bubble-right', 'bubble-left', 'bubble-top', 'bubble-bottom']; for (var i = 0; i < styleClassList.length; ++i) this.classList.toggle(styleClassList[i], i == attachment); }, /** * Shows the bubble for given anchor element. * @param {!Object} pos Bubble position (left, top, right, bottom in px). * @param {HTMLElement} opt_content Content to show in bubble. * If not specified, bubble element content is shown. * @param {Attachment=} opt_attachment Bubble attachment (on which side of * the element it should be displayed). * @param {boolean=} opt_oldstyle Optional flag to force old style bubble, * i.e. pre-MD-style. * @private */ showContentAt_: function(pos, opt_content, opt_attachment, opt_oldstyle) { this.style.top = this.style.left = this.style.right = this.style.bottom = 'auto'; for (var k in pos) { if (typeof pos[k] == 'number') this.style[k] = pos[k] + 'px'; } if (opt_content !== undefined) this.replaceContent(opt_content); if (opt_oldstyle) { this.setAttribute('oldstyle', ''); this.setAttachment_(opt_attachment); } this.hidden = false; this.classList.remove('faded'); }, /** * Replaces error message content with the given DOM element. * @param {HTMLElement} content Content to show in bubble. */ replaceContent: function(content) { this.innerHTML = ''; this.appendChild(content); }, /** * Shows the bubble for given anchor element. Bubble content is not cleared. * @param {!HTMLElement} el Anchor element of the bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {number=} opt_offset Offset of the bubble. * @param {number=} opt_padding Optional padding of the bubble. */ showForElement: function(el, attachment, opt_offset, opt_padding) { /* showForElement() is used only to display Accessibility popup in * oobe_screen_network*. It requires old-style bubble, so it is safe * to always set this flag here. */ this.showContentForElement( el, attachment, undefined, opt_offset, opt_padding, undefined, true); }, /** * Shows the bubble for given anchor element. * @param {!HTMLElement} el Anchor element of the bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {HTMLElement} opt_content Content to show in bubble. * If not specified, bubble element content is shown. * @param {number=} opt_offset Offset of the bubble attachment point from * left (for vertical attachment) or top (for horizontal attachment) * side of the element. If not specified, the bubble is positioned to * be aligned with the left/top side of the element but not farther than * half of its width/height. * @param {number=} opt_padding Optional padding of the bubble. * @param {boolean=} opt_match_width Optional flag to force the bubble have * the same width as the element it it attached to. * @param {boolean=} opt_oldstyle Optional flag to force old style bubble, * i.e. pre-MD-style. */ showContentForElement: function(el, attachment, opt_content, opt_offset, opt_padding, opt_match_width, opt_oldstyle) { /** @const */ var ARROW_OFFSET = 25; /** @const */ var DEFAULT_PADDING = 18; if (opt_padding == undefined) opt_padding = DEFAULT_PADDING; if (!opt_oldstyle) opt_padding += 10; var origin = cr.ui.login.DisplayManager.getPosition(el); var offset = opt_offset == undefined ? [Math.min(ARROW_OFFSET, el.offsetWidth / 2), Math.min(ARROW_OFFSET, el.offsetHeight / 2)] : [opt_offset, opt_offset]; var pos = {}; if (isRTL()) { switch (attachment) { case Bubble.Attachment.TOP: pos.right = origin.right + offset[0] - ARROW_OFFSET; pos.bottom = origin.bottom + el.offsetHeight + opt_padding; break; case Bubble.Attachment.RIGHT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.right = origin.right + el.offsetWidth + opt_padding; break; case Bubble.Attachment.BOTTOM: pos.right = origin.right + offset[0] - ARROW_OFFSET; pos.top = origin.top + el.offsetHeight + opt_padding; break; case Bubble.Attachment.LEFT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.left = origin.left + el.offsetWidth + opt_padding; break; } } else { switch (attachment) { case Bubble.Attachment.TOP: pos.left = origin.left + offset[0] - ARROW_OFFSET; pos.bottom = origin.bottom + el.offsetHeight + opt_padding; break; case Bubble.Attachment.RIGHT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.left = origin.left + el.offsetWidth + opt_padding; break; case Bubble.Attachment.BOTTOM: pos.left = origin.left + offset[0] - ARROW_OFFSET; pos.top = origin.top + el.offsetHeight + opt_padding; break; case Bubble.Attachment.LEFT: pos.top = origin.top + offset[1] - ARROW_OFFSET; pos.right = origin.right + el.offsetWidth + opt_padding; break; } } this.style.width = ''; this.removeAttribute('match-width'); if (opt_match_width) { this.setAttribute('match-width', ''); var elWidth = window.getComputedStyle(el, null).getPropertyValue('width'); var paddingLeft = parseInt(window.getComputedStyle(this, null) .getPropertyValue('padding-left')); var paddingRight = parseInt(window.getComputedStyle(this, null) .getPropertyValue('padding-right')); if (elWidth) this.style.width = (parseInt(elWidth) - paddingLeft - paddingRight) + 'px'; } this.anchor_ = el; this.showContentAt_(pos, opt_content, attachment, opt_oldstyle); }, /** * Shows the bubble for given anchor element. * @param {!HTMLElement} el Anchor element of the bubble. * @param {string} text Text content to show in bubble. * @param {!Attachment} attachment Bubble attachment (on which side of the * element it should be displayed). * @param {number=} opt_offset Offset of the bubble attachment point from * left (for vertical attachment) or top (for horizontal attachment) * side of the element. If not specified, the bubble is positioned to * be aligned with the left/top side of the element but not farther than * half of its weight/height. * @param {number=} opt_padding Optional padding of the bubble. */ showTextForElement: function(el, text, attachment, opt_offset, opt_padding) { var span = this.ownerDocument.createElement('span'); span.textContent = text; this.showContentForElement(el, attachment, span, opt_offset, opt_padding); }, /** * Hides the bubble. */ hide: function() { if (!this.classList.contains('faded')) this.classList.add('faded'); }, /** * Hides the bubble anchored to the given element (if any). * @param {!Object} el Anchor element. */ hideForElement: function(el) { if (!this.hidden && this.anchor_ == el) this.hide(); }, /** * Handler for faded transition end. * @private */ handleTransitionEnd_: function(e) { if (this.classList.contains('faded')) { this.hidden = true; if (this.elementToFocusOnHide_) this.elementToFocusOnHide_.focus(); } }, /** * Handler of scroll event. * @private */ handleScroll_: function(e) { if (!this.hidden) this.hide(); }, /** * Handler of document click event. * @private */ handleDocClick_: function(e) { // Ignore clicks on anchor element. if (e.target == this.anchor_) return; if (!this.hidden) this.hide(); }, /** * Handle of document keydown event. * @private */ handleDocKeyDown_: function(e) { if (this.hidden) return; if (this.hideOnKeyPress_) { this.hide(); return; } // Artificial tab-cycle. if (e.key == Keys.TAB && e.shiftKey == true && e.target == this.firstBubbleElement_) { this.lastBubbleElement_.focus(); e.preventDefault(); } if (e.key == Keys.TAB && e.shiftKey == false && e.target == this.lastBubbleElement_) { this.firstBubbleElement_.focus(); e.preventDefault(); } // Close bubble on ESC or on hitting spacebar or Enter at close-button. if (e.key == Keys.ESC || ((e.key == Keys.ENTER || e.key == Keys.SPACE) && e.target && e.target.classList.contains('close-button'))) this.hide(); }, /** * Handler of window blur event. * @private */ handleWindowBlur_: function(e) { if (!this.hidden) this.hide(); } }; return { Bubble: Bubble }; }); // // 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 JS helpers used on login. */ cr.define('cr.ui.LoginUITools', function() { return { /** * Computes max-height for an element so that it doesn't overlap shelf. * @param {element} DOM element * @param {wholeWindow} Whether the element can go outside outer-container. */ getMaxHeightBeforeShelfOverlapping : function(element, wholeWindow) { var maxAllowedHeight = $('outer-container').offsetHeight - element.getBoundingClientRect().top - parseInt(window.getComputedStyle(element).marginTop) - parseInt(window.getComputedStyle(element).marginBottom); if (wholeWindow) { maxAllowedHeight += parseInt(window.getComputedStyle($('outer-container')).bottom); } return maxAllowedHeight; }, /** * Computes max-width for an element so that it does fit the * outer-container. * @param {element} DOM element */ getMaxWidthToFit : function(element) { var maxAllowedWidth = $('outer-container').offsetWidth - element.getBoundingClientRect().left - parseInt(window.getComputedStyle(element).marginLeft) - parseInt(window.getComputedStyle(element).marginRight); return maxAllowedWidth; }, } }); // // 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 Display manager for WebUI OOBE and login. */ // TODO(xiyuan): Find a better to share those constants. /** @const */ var SCREEN_OOBE_NETWORK = 'connect'; /** @const */ var SCREEN_OOBE_HID_DETECTION = 'hid-detection'; /** @const */ var SCREEN_OOBE_EULA = 'eula'; /** @const */ var SCREEN_OOBE_ENABLE_DEBUGGING = 'debugging'; /** @const */ var SCREEN_OOBE_UPDATE = 'update'; /** @const */ var SCREEN_OOBE_RESET = 'reset'; /** @const */ var SCREEN_OOBE_ENROLLMENT = 'oauth-enrollment'; /** @const */ var SCREEN_OOBE_KIOSK_ENABLE = 'kiosk-enable'; /** @const */ var SCREEN_OOBE_AUTO_ENROLLMENT_CHECK = 'auto-enrollment-check'; /** @const */ var SCREEN_GAIA_SIGNIN = 'gaia-signin'; /** @const */ var SCREEN_ACCOUNT_PICKER = 'account-picker'; /** @const */ var SCREEN_USER_IMAGE_PICKER = 'user-image'; /** @const */ var SCREEN_ERROR_MESSAGE = 'error-message'; /** @const */ var SCREEN_TPM_ERROR = 'tpm-error-message'; /** @const */ var SCREEN_PASSWORD_CHANGED = 'password-changed'; /** @const */ var SCREEN_CREATE_SUPERVISED_USER_FLOW = 'supervised-user-creation'; /** @const */ var SCREEN_APP_LAUNCH_SPLASH = 'app-launch-splash'; /** @const */ var SCREEN_ARC_KIOSK_SPLASH = 'arc-kiosk-splash'; /** @const */ var SCREEN_CONFIRM_PASSWORD = 'confirm-password'; /** @const */ var SCREEN_FATAL_ERROR = 'fatal-error'; /** @const */ var SCREEN_KIOSK_ENABLE = 'kiosk-enable'; /** @const */ var SCREEN_TERMS_OF_SERVICE = 'terms-of-service'; /** @const */ var SCREEN_ARC_TERMS_OF_SERVICE = 'arc-tos'; /** @const */ var SCREEN_WRONG_HWID = 'wrong-hwid'; /** @const */ var SCREEN_DEVICE_DISABLED = 'device-disabled'; /** @const */ var SCREEN_UPDATE_REQUIRED = 'update-required'; /** @const */ var SCREEN_UNRECOVERABLE_CRYPTOHOME_ERROR = 'unrecoverable-cryptohome-error'; /** @const */ var SCREEN_ACTIVE_DIRECTORY_PASSWORD_CHANGE = 'ad-password-change'; /* Accelerator identifiers. Must be kept in sync with webui_login_view.cc. */ /** @const */ var ACCELERATOR_CANCEL = 'cancel'; /** @const */ var ACCELERATOR_ENABLE_DEBBUGING = 'debugging'; /** @const */ var ACCELERATOR_ENROLLMENT = 'enrollment'; /** @const */ var ACCELERATOR_KIOSK_ENABLE = 'kiosk_enable'; /** @const */ var ACCELERATOR_VERSION = 'version'; /** @const */ var ACCELERATOR_RESET = 'reset'; /** @const */ var ACCELERATOR_DEVICE_REQUISITION = 'device_requisition'; /** @const */ var ACCELERATOR_DEVICE_REQUISITION_REMORA = 'device_requisition_remora'; /** @const */ var ACCELERATOR_DEVICE_REQUISITION_SHARK = 'device_requisition_shark'; /** @const */ var ACCELERATOR_APP_LAUNCH_BAILOUT = 'app_launch_bailout'; /** @const */ var ACCELERATOR_APP_LAUNCH_NETWORK_CONFIG = 'app_launch_network_config'; /** @const */ var ACCELERATOR_BOOTSTRAPPING_SLAVE = "bootstrapping_slave"; /* Signin UI state constants. Used to control header bar UI. */ /** @const */ var SIGNIN_UI_STATE = { HIDDEN: 0, GAIA_SIGNIN: 1, ACCOUNT_PICKER: 2, WRONG_HWID_WARNING: 3, SUPERVISED_USER_CREATION_FLOW: 4, SAML_PASSWORD_CONFIRM: 5, PASSWORD_CHANGED: 6, ENROLLMENT: 7, ERROR: 8 }; /* Possible UI states of the error screen. */ /** @const */ var ERROR_SCREEN_UI_STATE = { UNKNOWN: 'ui-state-unknown', UPDATE: 'ui-state-update', SIGNIN: 'ui-state-signin', SUPERVISED_USER_CREATION_FLOW: 'ui-state-supervised', KIOSK_MODE: 'ui-state-kiosk-mode', LOCAL_STATE_ERROR: 'ui-state-local-state-error', AUTO_ENROLLMENT_ERROR: 'ui-state-auto-enrollment-error', ROLLBACK_ERROR: 'ui-state-rollback-error' }; /* Possible types of UI. */ /** @const */ var DISPLAY_TYPE = { UNKNOWN: 'unknown', OOBE: 'oobe', LOGIN: 'login', LOCK: 'lock', USER_ADDING: 'user-adding', APP_LAUNCH_SPLASH: 'app-launch-splash', ARC_KIOSK_SPLASH: 'arc-kiosk-splash', DESKTOP_USER_MANAGER: 'login-add-user' }; /* Possible lock screen enabled app activity state. */ /** @const */ var LOCK_SCREEN_APPS_STATE = { // No lock screen enabled app available. NONE: 'LOCK_SCREEN_APPS_STATE.NONE', // A lock screen enabled note taking app is available, but has not been // launched to handle a lock screen action. AVAILABLE: 'LOCK_SCREEN_APPS_STATE.AVAILABLE', // A lock screen enabled app is running in background - behind lock screen UI. BACKGROUND: 'LOCK_SCREEN_APPS_STATE.BACKGROUND', // A lock screen enabled app is running in foreground - an app window is // shown over the lock screen user pods (header bar should still be visible). FOREGROUND: 'LOCK_SCREEN_APPS_STATE.FOREGROUND', }; /** @const */ var USER_ACTION_ROLLBACK_TOGGLED = 'rollback-toggled'; cr.define('cr.ui.login', function() { var Bubble = cr.ui.Bubble; /** * Maximum time in milliseconds to wait for step transition to finish. * The value is used as the duration for ensureTransitionEndEvent below. * It needs to be inline with the step screen transition duration time * defined in css file. The current value in css is 200ms. To avoid emulated * transitionend fired before real one, 250ms is used. * @const */ var MAX_SCREEN_TRANSITION_DURATION = 250; /** * Groups of screens (screen IDs) that should have the same dimensions. * @type Array> * @const */ var SCREEN_GROUPS = [[SCREEN_OOBE_NETWORK, SCREEN_OOBE_EULA, SCREEN_OOBE_UPDATE, SCREEN_OOBE_AUTO_ENROLLMENT_CHECK] ]; /** * Group of screens (screen IDs) where factory-reset screen invocation is * available. * @type Array * @const */ var RESET_AVAILABLE_SCREEN_GROUP = [ SCREEN_OOBE_NETWORK, SCREEN_OOBE_EULA, SCREEN_OOBE_UPDATE, SCREEN_OOBE_ENROLLMENT, SCREEN_OOBE_AUTO_ENROLLMENT_CHECK, SCREEN_GAIA_SIGNIN, SCREEN_ACCOUNT_PICKER, SCREEN_KIOSK_ENABLE, SCREEN_ERROR_MESSAGE, SCREEN_USER_IMAGE_PICKER, SCREEN_TPM_ERROR, SCREEN_PASSWORD_CHANGED, SCREEN_TERMS_OF_SERVICE, SCREEN_ARC_TERMS_OF_SERVICE, SCREEN_WRONG_HWID, SCREEN_CONFIRM_PASSWORD, SCREEN_UPDATE_REQUIRED, SCREEN_FATAL_ERROR ]; /** * Group of screens (screen IDs) where enable debuggingscreen invocation is * available. * @type Array * @const */ var ENABLE_DEBUGGING_AVAILABLE_SCREEN_GROUP = [ SCREEN_OOBE_HID_DETECTION, SCREEN_OOBE_NETWORK, SCREEN_OOBE_EULA, SCREEN_OOBE_UPDATE, SCREEN_TERMS_OF_SERVICE ]; /** * Group of screens (screen IDs) that are not participating in * left-current-right animation. * @type Array * @const */ var NOT_ANIMATED_SCREEN_GROUP = [ SCREEN_OOBE_ENABLE_DEBUGGING, SCREEN_OOBE_RESET, ]; /** * OOBE screens group index. */ var SCREEN_GROUP_OOBE = 0; /** * Constructor a display manager that manages initialization of screens, * transitions, error messages display. * * @constructor */ function DisplayManager() { } DisplayManager.prototype = { /** * Registered screens. */ screens_: [], /** * Current OOBE step, index in the screens array. * @type {number} */ currentStep_: 0, /** * Whether version label can be toggled by ACCELERATOR_VERSION. * @type {boolean} */ allowToggleVersion_: false, /** * Whether keyboard navigation flow is enforced. * @type {boolean} */ forceKeyboardFlow_: false, /** * Whether the virtual keyboard is displayed. * @type {boolean} */ virtualKeyboardShown: false, /** * Type of UI. * @type {string} */ displayType_: DISPLAY_TYPE.UNKNOWN, /** * Error message (bubble) was shown. This is checked in tests. */ errorMessageWasShownForTesting_: false, get displayType() { return this.displayType_; }, set displayType(displayType) { this.displayType_ = displayType; document.documentElement.setAttribute('screen', displayType); }, get newKioskUI() { return loadTimeData.getString('newKioskUI') == 'on'; }, /** * Returns dimensions of screen exluding header bar. * @type {Object} */ get clientAreaSize() { var container = $('outer-container'); return {width: container.offsetWidth, height: container.offsetHeight}; }, /** * Gets current screen element. * @type {HTMLElement} */ get currentScreen() { return $(this.screens_[this.currentStep_]); }, /** * Hides/shows header (Shutdown/Add User/Cancel buttons). * @param {boolean} hidden Whether header is hidden. */ get headerHidden() { return $('login-header-bar').hidden; }, set headerHidden(hidden) { if (this.showingViewsBasedShelf && !hidden) { // When views-based shelf is enabled, toggling header bar visibility // is handled by ash. Prevent showing a duplicate header bar here. return; } $('login-header-bar').hidden = hidden; }, /** * The header bar should be hidden when views-based shelf is shown. */ get showingViewsBasedShelf() { return loadTimeData.valueExists('showMdLogin') && loadTimeData.getString('showMdLogin') == 'on' && (this.displayType_ == DISPLAY_TYPE.LOCK || this.displayType_ == DISPLAY_TYPE.USER_ADDING); }, /** * Sets the current size of the client area (display size). * @param {number} width client area width * @param {number} height client area height */ setClientAreaSize: function(width, height) { var clientArea = $('outer-container'); var bottom = parseInt(window.getComputedStyle(clientArea).bottom); clientArea.style.minHeight = cr.ui.toCssPx(height - bottom); }, /** * Toggles background of main body between transparency and solid. * @param {boolean} solid Whether to show a solid background. */ set solidBackground(solid) { if (solid) document.body.classList.add('solid'); else document.body.classList.remove('solid'); }, /** * Forces keyboard based OOBE navigation. * @param {boolean} value True if keyboard navigation flow is forced. */ set forceKeyboardFlow(value) { this.forceKeyboardFlow_ = value; if (value) { keyboard.initializeKeyboardFlow(false); cr.ui.DropDown.enableKeyboardFlow(); for (var i = 0; i < this.screens_.length; ++i) { var screen = $(this.screens_[i]); if (screen.enableKeyboardFlow) screen.enableKeyboardFlow(); } } }, /** * Returns true if keyboard flow is enabled. * @return {boolean} */ get forceKeyboardFlow() { return this.forceKeyboardFlow_; }, /** * Shows/hides version labels. * @param {boolean} show Whether labels should be visible by default. If * false, visibility can be toggled by ACCELERATOR_VERSION. */ showVersion: function(show) { $('version-labels').hidden = !show; this.allowToggleVersion_ = !show; }, /** * Handle accelerators. * @param {string} name Accelerator name. */ handleAccelerator: function(name) { if (this.currentScreen.ignoreAccelerators) { return; } var currentStepId = this.screens_[this.currentStep_]; if (name == ACCELERATOR_CANCEL) { if (this.currentScreen.cancel) { this.currentScreen.cancel(); } } else if (name == ACCELERATOR_ENABLE_DEBBUGING) { if (ENABLE_DEBUGGING_AVAILABLE_SCREEN_GROUP.indexOf( currentStepId) != -1) { chrome.send('toggleEnableDebuggingScreen'); } } else if (name == ACCELERATOR_ENROLLMENT) { if (currentStepId == SCREEN_GAIA_SIGNIN || currentStepId == SCREEN_ACCOUNT_PICKER) { chrome.send('toggleEnrollmentScreen'); } else if (currentStepId == SCREEN_OOBE_NETWORK || currentStepId == SCREEN_OOBE_EULA) { // In this case update check will be skipped and OOBE will // proceed straight to enrollment screen when EULA is accepted. chrome.send('skipUpdateEnrollAfterEula'); } } else if (name == ACCELERATOR_KIOSK_ENABLE) { if (currentStepId == SCREEN_GAIA_SIGNIN || currentStepId == SCREEN_ACCOUNT_PICKER) { chrome.send('toggleKioskEnableScreen'); } } else if (name == ACCELERATOR_VERSION) { if (this.allowToggleVersion_) $('version-labels').hidden = !$('version-labels').hidden; } else if (name == ACCELERATOR_RESET) { if (currentStepId == SCREEN_OOBE_RESET) $('reset').send(login.Screen.CALLBACK_USER_ACTED, USER_ACTION_ROLLBACK_TOGGLED); else if (RESET_AVAILABLE_SCREEN_GROUP.indexOf(currentStepId) != -1) chrome.send('toggleResetScreen'); } else if (name == ACCELERATOR_DEVICE_REQUISITION) { if (this.isOobeUI()) this.showDeviceRequisitionPrompt_(); } else if (name == ACCELERATOR_DEVICE_REQUISITION_REMORA) { if (this.isOobeUI()) this.showDeviceRequisitionRemoraPrompt_( 'deviceRequisitionRemoraPromptText', 'remora'); } else if (name == ACCELERATOR_DEVICE_REQUISITION_SHARK) { if (this.isOobeUI()) this.showDeviceRequisitionRemoraPrompt_( 'deviceRequisitionSharkPromptText', 'shark'); } else if (name == ACCELERATOR_APP_LAUNCH_BAILOUT) { if (currentStepId == SCREEN_APP_LAUNCH_SPLASH) chrome.send('cancelAppLaunch'); if (currentStepId == SCREEN_ARC_KIOSK_SPLASH) chrome.send('cancelArcKioskLaunch'); } else if (name == ACCELERATOR_APP_LAUNCH_NETWORK_CONFIG) { if (currentStepId == SCREEN_APP_LAUNCH_SPLASH) chrome.send('networkConfigRequest'); } else if (name == ACCELERATOR_BOOTSTRAPPING_SLAVE) { chrome.send('setOobeBootstrappingSlave'); } }, /** * Appends buttons to the button strip. * @param {Array} buttons Array with the buttons to append. * @param {string} screenId Id of the screen that buttons belong to. */ appendButtons_: function(buttons, screenId) { if (buttons) { var buttonStrip = $(screenId + '-controls'); if (buttonStrip) { for (var i = 0; i < buttons.length; ++i) buttonStrip.appendChild(buttons[i]); } } }, /** * Disables or enables control buttons on the specified screen. * @param {HTMLElement} screen Screen which controls should be affected. * @param {boolean} disabled Whether to disable controls. */ disableButtons_: function(screen, disabled) { var buttons = document.querySelectorAll( '#' + screen.id + '-controls button:not(.preserve-disabled-state)'); for (var i = 0; i < buttons.length; ++i) { buttons[i].disabled = disabled; } }, screenIsAnimated_: function(screenId) { return NOT_ANIMATED_SCREEN_GROUP.indexOf(screenId) != -1; }, /** * Updates a step's css classes to reflect left, current, or right position. * @param {number} stepIndex step index. * @param {string} state one of 'left', 'current', 'right'. */ updateStep_: function(stepIndex, state) { var stepId = this.screens_[stepIndex]; var step = $(stepId); var header = $('header-' + stepId); var states = ['left', 'right', 'current']; for (var i = 0; i < states.length; ++i) { if (states[i] != state) { step.classList.remove(states[i]); header.classList.remove(states[i]); } } step.classList.add(state); header.classList.add(state); }, /** * Switches to the next OOBE step. * @param {number} nextStepIndex Index of the next step. */ toggleStep_: function(nextStepIndex, screenData) { var currentStepId = this.screens_[this.currentStep_]; var nextStepId = this.screens_[nextStepIndex]; var oldStep = $(currentStepId); var newStep = $(nextStepId); var newHeader = $('header-' + nextStepId); // Disable controls before starting animation. this.disableButtons_(oldStep, true); if (oldStep.onBeforeHide) oldStep.onBeforeHide(); $('oobe').className = nextStepId; // Need to do this before calling newStep.onBeforeShow() so that new step // is back in DOM tree and has correct offsetHeight / offsetWidth. newStep.hidden = false; if (newStep.onBeforeShow) newStep.onBeforeShow(screenData); newStep.classList.remove('hidden'); if (this.isOobeUI() && this.screenIsAnimated_(nextStepId) && this.screenIsAnimated_(currentStepId)) { // Start gliding animation for OOBE steps. if (nextStepIndex > this.currentStep_) { for (var i = this.currentStep_; i < nextStepIndex; ++i) this.updateStep_(i, 'left'); this.updateStep_(nextStepIndex, 'current'); } else if (nextStepIndex < this.currentStep_) { for (var i = this.currentStep_; i > nextStepIndex; --i) this.updateStep_(i, 'right'); this.updateStep_(nextStepIndex, 'current'); } } else { // Start fading animation for login display or reset screen. oldStep.classList.add('faded'); newStep.classList.remove('faded'); if (!this.screenIsAnimated_(nextStepId)) { newStep.classList.remove('left'); newStep.classList.remove('right'); } } this.disableButtons_(newStep, false); // Adjust inner container height based on new step's height. this.updateScreenSize(newStep); if (newStep.onAfterShow) newStep.onAfterShow(screenData); // Workaround for gaia and network screens. // Due to other origin iframe and long ChromeVox focusing correspondingly // passive aria-label title is not pronounced. // Gaia hack can be removed on fixed crbug.com/316726. if (nextStepId == SCREEN_GAIA_SIGNIN || nextStepId == SCREEN_OOBE_ENROLLMENT) { newStep.setAttribute( 'aria-label', loadTimeData.getString('signinScreenTitle')); } else if (nextStepId == SCREEN_OOBE_NETWORK) { newStep.setAttribute( 'aria-label', loadTimeData.getString('networkScreenAccessibleTitle')); } // Default control to be focused (if specified). var defaultControl = newStep.defaultControl; var outerContainer = $('outer-container'); var innerContainer = $('inner-container'); var isOOBE = this.isOobeUI(); if (this.currentStep_ != nextStepIndex && !oldStep.classList.contains('hidden')) { if (oldStep.classList.contains('animated')) { innerContainer.classList.add('animation'); oldStep.addEventListener('transitionend', function f(e) { oldStep.removeEventListener('transitionend', f); if (oldStep.classList.contains('faded') || oldStep.classList.contains('left') || oldStep.classList.contains('right')) { innerContainer.classList.remove('animation'); oldStep.classList.add('hidden'); if (!isOOBE) oldStep.hidden = true; } // Refresh defaultControl. It could have changed. var defaultControl = newStep.defaultControl; if (defaultControl) defaultControl.focus(); }); ensureTransitionEndEvent(oldStep, MAX_SCREEN_TRANSITION_DURATION); } else { oldStep.classList.add('hidden'); oldStep.hidden = true; if (defaultControl) defaultControl.focus(); } } else { // First screen on OOBE launch. if (this.isOobeUI() && innerContainer.classList.contains('down')) { innerContainer.classList.remove('down'); innerContainer.addEventListener( 'transitionend', function f(e) { innerContainer.removeEventListener('transitionend', f); outerContainer.classList.remove('down'); $('progress-dots').classList.remove('down'); chrome.send('loginVisible', ['oobe']); // Refresh defaultControl. It could have changed. var defaultControl = newStep.defaultControl; if (defaultControl) defaultControl.focus(); }); ensureTransitionEndEvent(innerContainer, MAX_SCREEN_TRANSITION_DURATION); } else { if (defaultControl) defaultControl.focus(); chrome.send('loginVisible', ['oobe']); } } this.currentStep_ = nextStepIndex; $('step-logo').hidden = newStep.classList.contains('no-logo'); $('oobe').dispatchEvent( new CustomEvent('screenchanged', {detail: this.currentScreen.id})); chrome.send('updateCurrentScreen', [this.currentScreen.id]); }, /** * Make sure that screen is initialized and decorated. * @param {Object} screen Screen params dict, e.g. {id: screenId, data: {}}. */ preloadScreen: function(screen) { var screenEl = $(screen.id); if (screenEl.deferredInitialization !== undefined) { screenEl.deferredInitialization(); delete screenEl.deferredInitialization; } }, /** * Show screen of given screen id. * @param {Object} screen Screen params dict, e.g. {id: screenId, data: {}}. */ showScreen: function(screen) { // Do not allow any other screen to clobber the device disabled screen. if (this.currentScreen.id == SCREEN_DEVICE_DISABLED) return; var screenId = screen.id; // Make sure the screen is decorated. this.preloadScreen(screen); if (screen.data !== undefined && screen.data.disableAddUser) DisplayManager.updateAddUserButtonStatus(true); // Show sign-in screen instead of account picker if pod row is empty. if (screenId == SCREEN_ACCOUNT_PICKER && $('pod-row').pods.length == 0 && cr.isChromeOS) { // Manually hide 'add-user' header bar, because of the case when // 'Cancel' button is used on the offline login page. $('add-user-header-bar-item').hidden = true; Oobe.showSigninUI(); return; } var data = screen.data; var index = this.getScreenIndex_(screenId); if (index >= 0) this.toggleStep_(index, data); }, /** * Gets index of given screen id in screens_. * @param {string} screenId Id of the screen to look up. * @private */ getScreenIndex_: function(screenId) { for (var i = 0; i < this.screens_.length; ++i) { if (this.screens_[i] == screenId) return i; } return -1; }, /** * Register an oobe screen. * @param {Element} el Decorated screen element. */ registerScreen: function(el) { var screenId = el.id; this.screens_.push(screenId); var header = document.createElement('span'); header.id = 'header-' + screenId; header.textContent = el.header ? el.header : ''; header.className = 'header-section'; $('header-sections').appendChild(header); var dot = document.createElement('div'); dot.id = screenId + '-dot'; dot.className = 'progdot'; var progressDots = $('progress-dots'); if (progressDots) progressDots.appendChild(dot); this.appendButtons_(el.buttons, screenId); }, /** * Updates inner container size based on the size of the current screen and * other screens in the same group. * Should be executed on screen change / screen size change. * @param {!HTMLElement} screen Screen that is being shown. */ updateScreenSize: function(screen) { // Have to reset any previously predefined screen size first // so that screen contents would define it instead. $('inner-container').style.height = ''; $('inner-container').style.width = ''; screen.style.width = ''; screen.style.height = ''; $('outer-container').classList.toggle( 'fullscreen', screen.classList.contains('fullscreen')); var width = screen.getPreferredSize().width; var height = screen.getPreferredSize().height; for (var i = 0, screenGroup; screenGroup = SCREEN_GROUPS[i]; i++) { if (screenGroup.indexOf(screen.id) != -1) { // Set screen dimensions to maximum dimensions within this group. for (var j = 0, screen2; screen2 = $(screenGroup[j]); j++) { width = Math.max(width, screen2.getPreferredSize().width); height = Math.max(height, screen2.getPreferredSize().height); } break; } } $('inner-container').style.height = height + 'px'; $('inner-container').style.width = width + 'px'; // This requires |screen| to have 'box-sizing: border-box'. screen.style.width = width + 'px'; screen.style.height = height + 'px'; }, /** * Updates localized content of the screens like headers, buttons and links. * Should be executed on language change. */ updateLocalizedContent_: function() { for (var i = 0, screenId; screenId = this.screens_[i]; ++i) { var screen = $(screenId); var buttonStrip = $(screenId + '-controls'); if (buttonStrip) buttonStrip.innerHTML = ''; // TODO(nkostylev): Update screen headers for new OOBE design. this.appendButtons_(screen.buttons, screenId); if (screen.updateLocalizedContent) screen.updateLocalizedContent(); } var currentScreenId = this.screens_[this.currentStep_]; var currentScreen = $(currentScreenId); this.updateScreenSize(currentScreen); // Trigger network drop-down to reload its state // so that strings are reloaded. // Will be reloaded if drowdown is actually shown. cr.ui.DropDown.refresh(); }, /** * Initialized first group of OOBE screens. */ initializeOOBEScreens: function() { if (this.isOobeUI() && $('inner-container').classList.contains('down')) { for (var i = 0, screen; screen = $(SCREEN_GROUPS[SCREEN_GROUP_OOBE][i]); i++) { screen.hidden = false; } } }, /** * Prepares screens to use in login display. */ prepareForLoginDisplay_: function() { for (var i = 0, screenId; screenId = this.screens_[i]; ++i) { var screen = $(screenId); screen.classList.add('faded'); screen.classList.remove('right'); screen.classList.remove('left'); } }, /** * Shows the device requisition prompt. */ showDeviceRequisitionPrompt_: function() { if (!this.deviceRequisitionDialog_) { this.deviceRequisitionDialog_ = new cr.ui.dialogs.PromptDialog(document.body); this.deviceRequisitionDialog_.setOkLabel( loadTimeData.getString('deviceRequisitionPromptOk')); this.deviceRequisitionDialog_.setCancelLabel( loadTimeData.getString('deviceRequisitionPromptCancel')); } this.deviceRequisitionDialog_.show( loadTimeData.getString('deviceRequisitionPromptText'), this.deviceRequisition_, this.onConfirmDeviceRequisitionPrompt_.bind(this)); }, /** * Confirmation handle for the device requisition prompt. * @param {string} value The value entered by the user. * @private */ onConfirmDeviceRequisitionPrompt_: function(value) { this.deviceRequisition_ = value; chrome.send('setDeviceRequisition', [value == '' ? 'none' : value]); }, /** * Called when window size changed. Notifies current screen about change. * @private */ onWindowResize_: function() { var currentScreenId = this.screens_[this.currentStep_]; var currentScreen = $(currentScreenId); if (currentScreen) currentScreen.onWindowResize(); // The account picker always needs to be notified of window size changes. if (currentScreenId != SCREEN_ACCOUNT_PICKER && $(SCREEN_ACCOUNT_PICKER)) $(SCREEN_ACCOUNT_PICKER).onWindowResize(); }, /* * Updates the device requisition string shown in the requisition prompt. * @param {string} requisition The device requisition. */ updateDeviceRequisition: function(requisition) { this.deviceRequisition_ = requisition; }, /** * Shows the special remora/shark device requisition prompt. * @private */ showDeviceRequisitionRemoraPrompt_: function(promptText, requisition) { if (!this.deviceRequisitionRemoraDialog_) { this.deviceRequisitionRemoraDialog_ = new cr.ui.dialogs.ConfirmDialog(document.body); this.deviceRequisitionRemoraDialog_.setOkLabel( loadTimeData.getString('deviceRequisitionRemoraPromptOk')); this.deviceRequisitionRemoraDialog_.setCancelLabel( loadTimeData.getString('deviceRequisitionRemoraPromptCancel')); } this.deviceRequisitionRemoraDialog_.show( loadTimeData.getString(promptText), function() { // onShow chrome.send('setDeviceRequisition', [requisition]); }, function() { // onCancel chrome.send('setDeviceRequisition', ['none']); }); }, /** * Returns true if Oobe UI is shown. */ isOobeUI: function() { return document.body.classList.contains('oobe-display'); }, /** * Sets or unsets given |className| for top-level container. Useful for * customizing #inner-container with CSS rules. All classes set with with * this method will be removed after screen change. * @param {string} className Class to toggle. * @param {boolean} enabled Whether class should be enabled or disabled. */ toggleClass: function(className, enabled) { $('oobe').classList.toggle(className, enabled); } }; /** * Initializes display manager. */ DisplayManager.initialize = function() { var givenDisplayType = DISPLAY_TYPE.UNKNOWN; if (document.documentElement.hasAttribute('screen')) { // Display type set in HTML property. givenDisplayType = document.documentElement.getAttribute('screen'); } else { // Extracting display type from URL. givenDisplayType = window.location.pathname.substr(1); } var instance = Oobe.getInstance(); Object.getOwnPropertyNames(DISPLAY_TYPE).forEach(function(type) { if (DISPLAY_TYPE[type] == givenDisplayType) { instance.displayType = givenDisplayType; } }); if (instance.displayType == DISPLAY_TYPE.UNKNOWN) { console.error("Unknown display type '" + givenDisplayType + "'. Setting default."); instance.displayType = DISPLAY_TYPE.LOGIN; } instance.initializeOOBEScreens(); window.addEventListener('resize', instance.onWindowResize_.bind(instance)); }; /** * Returns offset (top, left) of the element. * @param {!Element} element HTML element. * @return {!Object} The offset (top, left). */ DisplayManager.getOffset = function(element) { var x = 0; var y = 0; while (element && !isNaN(element.offsetLeft) && !isNaN(element.offsetTop)) { x += element.offsetLeft - element.scrollLeft; y += element.offsetTop - element.scrollTop; element = element.offsetParent; } return { top: y, left: x }; }; /** * Returns position (top, left, right, bottom) of the element. * @param {!Element} element HTML element. * @return {!Object} Element position (top, left, right, bottom). */ DisplayManager.getPosition = function(element) { var offset = DisplayManager.getOffset(element); return { top: offset.top, right: window.innerWidth - element.offsetWidth - offset.left, bottom: window.innerHeight - element.offsetHeight - offset.top, left: offset.left }; }; /** * Disables signin UI. */ DisplayManager.disableSigninUI = function() { $('login-header-bar').disabled = true; $('pod-row').disabled = true; }; /** * Shows signin UI. * @param {string} opt_email An optional email for signin UI. */ DisplayManager.showSigninUI = function(opt_email) { var currentScreenId = Oobe.getInstance().currentScreen.id; if (currentScreenId == SCREEN_GAIA_SIGNIN) $('login-header-bar').signinUIState = SIGNIN_UI_STATE.GAIA_SIGNIN; else if (currentScreenId == SCREEN_ACCOUNT_PICKER) $('login-header-bar').signinUIState = SIGNIN_UI_STATE.ACCOUNT_PICKER; chrome.send('showAddUser', [opt_email]); }; /** * Resets sign-in input fields. * @param {boolean} forceOnline Whether online sign-in should be forced. * If |forceOnline| is false previously used sign-in type will be used. */ DisplayManager.resetSigninUI = function(forceOnline) { var currentScreenId = Oobe.getInstance().currentScreen.id; if ($(SCREEN_GAIA_SIGNIN)) $(SCREEN_GAIA_SIGNIN).reset( currentScreenId == SCREEN_GAIA_SIGNIN, forceOnline); $('login-header-bar').disabled = false; $('pod-row').reset(currentScreenId == SCREEN_ACCOUNT_PICKER); }; /** * Shows sign-in error bubble. * @param {number} loginAttempts Number of login attemps tried. * @param {string} message Error message to show. * @param {string} link Text to use for help link. * @param {number} helpId Help topic Id associated with help link. */ DisplayManager.showSignInError = function(loginAttempts, message, link, helpId) { var error = document.createElement('div'); var messageDiv = document.createElement('div'); messageDiv.className = 'error-message-bubble'; messageDiv.textContent = message; error.appendChild(messageDiv); if (link) { messageDiv.classList.add('error-message-bubble-padding'); var helpLink = document.createElement('a'); helpLink.href = '#'; helpLink.textContent = link; helpLink.addEventListener('click', function(e) { chrome.send('launchHelpApp', [helpId]); e.preventDefault(); }); error.appendChild(helpLink); } error.setAttribute('aria-live', 'assertive'); var currentScreen = Oobe.getInstance().currentScreen; if (currentScreen && typeof currentScreen.showErrorBubble === 'function') { currentScreen.showErrorBubble(loginAttempts, error); this.errorMessageWasShownForTesting_ = true; } }; /** * Shows password changed screen that offers migration. * @param {boolean} showError Whether to show the incorrect password error. * @param {string} email What user does reauth. Being used for display in the * new UI. */ DisplayManager.showPasswordChangedScreen = function(showError, email) { login.PasswordChangedScreen.show(showError, email); }; /** * Shows dialog to create a supervised user. */ DisplayManager.showSupervisedUserCreationScreen = function() { login.SupervisedUserCreationScreen.show(); }; /** * Shows TPM error screen. */ DisplayManager.showTpmError = function() { login.TPMErrorMessageScreen.show(); }; /** * Shows password change screen for Active Directory users. * @param {string} username Display name of the user whose password is being * changed. */ DisplayManager.showActiveDirectoryPasswordChangeScreen = function(username) { login.ActiveDirectoryPasswordChangeScreen.show(username); }; /** * Clears error bubble. */ DisplayManager.clearErrors = function() { $('bubble').hide(); this.errorMessageWasShownForTesting_ = false; var bubbles = document.querySelectorAll('.bubble-shown'); for (var i = 0; i < bubbles.length; ++i) bubbles[i].classList.remove('bubble-shown'); }; /** * Sets text content for a div with |labelId|. * @param {string} labelId Id of the label div. * @param {string} labelText Text for the label. */ DisplayManager.setLabelText = function(labelId, labelText) { $(labelId).textContent = labelText; }; /** * Sets the text content of the enterprise info message and asset ID. * @param {string} messageText The message text. * @param {string} assetId The device asset ID. */ DisplayManager.setEnterpriseInfo = function(messageText, assetId) { $('asset-id').textContent = ((assetId == "") ? "" : loadTimeData.getStringF('assetIdLabel', assetId)); }; /** * Sets the text content of the Bluetooth device info message. * @param {string} bluetoothName The Bluetooth device name text. */ DisplayManager.setBluetoothDeviceInfo = function(bluetoothName) { $('bluetooth-name').hidden = false; $('bluetooth-name').textContent = bluetoothName; }; /** * Disable Add users button if said. * @param {boolean} disable true to disable */ DisplayManager.updateAddUserButtonStatus = function(disable) { $('add-user-button').disabled = disable; $('add-user-button').classList[ disable ? 'add' : 'remove']('button-restricted'); $('add-user-button').title = disable ? loadTimeData.getString('disabledAddUserTooltip') : ''; } /** * Clears password field in user-pod. */ DisplayManager.clearUserPodPassword = function() { $('pod-row').clearFocusedPod(); }; /** * Restores input focus to currently selected pod. */ DisplayManager.refocusCurrentPod = function() { $('pod-row').refocusCurrentPod(); }; // Export return { DisplayManager: DisplayManager }; }); // // 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 Account picker screen implementation. */ login.createScreen('AccountPickerScreen', 'account-picker', function() { /** * Maximum number of offline login failures before online login. * @type {number} * @const */ var MAX_LOGIN_ATTEMPTS_IN_POD = 3; /** * Distance between error bubble and user POD. * @type {number} * @const */ var BUBBLE_POD_OFFSET = 4; return { EXTERNAL_API: [ 'loadUsers', 'runAppForTesting', 'setApps', 'setShouldShowApps', 'showAppError', 'updateUserImage', 'setCapsLockState', 'forceLockedUserPodFocus', 'removeUser', 'showBannerMessage', 'showUserPodCustomIcon', 'hideUserPodCustomIcon', 'setUserPodFingerprintIcon', 'removeUserPodFingerprintIcon', 'setPinEnabledForUser', 'setAuthType', 'setTabletModeState', 'setPublicSessionDisplayName', 'setPublicSessionLocales', 'setPublicSessionKeyboardLayouts', 'setLockScreenAppsState', ], preferredWidth_: 0, preferredHeight_: 0, // Whether this screen is shown for the first time. firstShown_: true, // Whether this screen is currently being shown. showing_: false, // Last reported lock screen app activity state. lockScreenAppsState_: LOCK_SCREEN_APPS_STATE.NONE, /** @override */ decorate: function() { login.PodRow.decorate($('pod-row')); this.ownerDocument.addEventListener('click', this.handleOwnerDocClick_.bind(this)); }, /** @override */ getPreferredSize: function() { return {width: this.preferredWidth_, height: this.preferredHeight_}; }, /** @override */ onWindowResize: function() { $('pod-row').onWindowResize(); // Reposition the error bubble, if it is showing. Since we are just // moving the bubble, the number of login attempts tried doesn't matter. var errorBubble = $('bubble'); if (errorBubble && !errorBubble.hidden) this.showErrorBubble(0, undefined /* Reuses the existing message. */); }, /** * Sets preferred size for account picker screen. */ setPreferredSize: function(width, height) { this.preferredWidth_ = width; this.preferredHeight_ = height; }, /** * When the account picker is being used to lock the screen, pressing the * exit accelerator key will sign out the active user as it would when * they are signed in. */ exit: function() { // Check and disable the sign out button so that we can never have two // sign out requests generated in a row. if ($('pod-row').lockedPod && !$('sign-out-user-button').disabled) { $('sign-out-user-button').disabled = true; chrome.send('signOutUser'); } }, /* Cancel user adding if ESC was pressed. */ cancel: function() { if (Oobe.getInstance().displayType == DISPLAY_TYPE.USER_ADDING) chrome.send('cancelUserAdding'); }, /** * Event handler that is invoked just after the frame is shown. * @param {string} data Screen init payload. */ onAfterShow: function(data) { $('pod-row').handleAfterShow(); }, /** * Event handler that is invoked just before the frame is shown. * @param {string} data Screen init payload. */ onBeforeShow: function(data) { this.showing_ = true; chrome.send('loginUIStateChanged', ['account-picker', true]); $('login-header-bar').signinUIState = SIGNIN_UI_STATE.ACCOUNT_PICKER; // Header bar should be always visible on Account Picker screen. Oobe.getInstance().headerHidden = false; chrome.send('hideCaptivePortal'); var podRow = $('pod-row'); podRow.handleBeforeShow(); // In case of the preselected pod onShow will be called once pod // receives focus. if (!podRow.preselectedPod) this.onShow(); }, /** * Event handler invoked when the page is shown and ready. */ onShow: function() { if (!this.showing_) { // This method may be called asynchronously when the pod row finishes // initializing. However, at that point, the screen may have been hidden // again already. If that happens, ignore the onShow() call. return; } chrome.send('getTabletModeState'); if (!this.firstShown_) return; this.firstShown_ = false; // Ensure that login is actually visible. window.requestAnimationFrame(function() { chrome.send('accountPickerReady'); chrome.send('loginVisible', ['account-picker']); }); }, /** * Event handler that is invoked just before the frame is hidden. */ onBeforeHide: function() { $('pod-row').clearFocusedPod(); this.showing_ = false; chrome.send('loginUIStateChanged', ['account-picker', false]); $('login-header-bar').signinUIState = SIGNIN_UI_STATE.HIDDEN; $('pod-row').handleHide(); }, /** * Shows sign-in error bubble. * @param {number} loginAttempts Number of login attemps tried. * @param {HTMLElement} content Content to show in bubble. */ showErrorBubble: function(loginAttempts, error) { var activatedPod = $('pod-row').activatedPod; if (!activatedPod) { $('bubble').showContentForElement($('pod-row'), cr.ui.Bubble.Attachment.RIGHT, error); return; } // Show web authentication if this is not a supervised user. if (loginAttempts > MAX_LOGIN_ATTEMPTS_IN_POD && !activatedPod.user.supervisedUser) { chrome.send('maxIncorrectPasswordAttempts', [activatedPod.user.emailAddress]); activatedPod.showSigninUI(); } else { if (loginAttempts == 1) { chrome.send('firstIncorrectPasswordAttempt', [activatedPod.user.emailAddress]); } // Update the pod row display if incorrect password. $('pod-row').setFocusedPodErrorDisplay(true); /** @const */ var BUBBLE_OFFSET = 25; // -8 = 4(BUBBLE_POD_OFFSET) - 2(bubble margin) // - 10(internal bubble adjustment) var bubblePositioningPadding = -8; var bubbleAnchor; var attachment; if (activatedPod.pinContainer && activatedPod.pinContainer.style.visibility == 'visible') { // Anchor the bubble to the input field. bubbleAnchor = ( activatedPod.getElementsByClassName('auth-container'))[0]; if (!bubbleAnchor) { console.error('auth-container not found!'); bubbleAnchor = activatedPod.mainInput; } attachment = cr.ui.Bubble.Attachment.RIGHT; } else { // Anchor the bubble to the pod instead of the input. bubbleAnchor = activatedPod; attachment = cr.ui.Bubble.Attachment.BOTTOM; } var bubble = $('bubble'); // Cannot use cr.ui.LoginUITools.get* on bubble until it is attached to // the element. getMaxHeight/Width rely on the correct up/left element // side positioning that doesn't happen until bubble is attached. var maxHeight = cr.ui.LoginUITools.getMaxHeightBeforeShelfOverlapping(bubbleAnchor) - bubbleAnchor.offsetHeight - BUBBLE_POD_OFFSET; var maxWidth = cr.ui.LoginUITools.getMaxWidthToFit(bubbleAnchor) - bubbleAnchor.offsetWidth - BUBBLE_POD_OFFSET; // Change bubble visibility temporary to calculate height. var bubbleVisibility = bubble.style.visibility; bubble.style.visibility = 'hidden'; bubble.hidden = false; // Now we need the bubble to have the new content before calculating // size. Undefined |error| == reuse old content. if (error !== undefined) bubble.replaceContent(error); // Get bubble size. var bubbleOffsetHeight = parseInt(bubble.offsetHeight); var bubbleOffsetWidth = parseInt(bubble.offsetWidth); // Restore attributes. bubble.style.visibility = bubbleVisibility; bubble.hidden = true; if (attachment == cr.ui.Bubble.Attachment.BOTTOM) { // Move error bubble if it overlaps the shelf. if (maxHeight < bubbleOffsetHeight) attachment = cr.ui.Bubble.Attachment.TOP; } else { // Move error bubble if it doesn't fit screen. if (maxWidth < bubbleOffsetWidth) { bubblePositioningPadding = 2; attachment = cr.ui.Bubble.Attachment.LEFT; } } var showBubbleCallback = function() { activatedPod.removeEventListener("transitionend", showBubbleCallback); $('bubble').showContentForElement(bubbleAnchor, attachment, error, BUBBLE_OFFSET, bubblePositioningPadding, true); }; activatedPod.addEventListener("transitionend", showBubbleCallback); ensureTransitionEndEvent(activatedPod); } }, /** * Loads given users in pod row. * @param {array} users Array of user. * @param {boolean} showGuest Whether to show guest session button. */ loadUsers: function(users, showGuest) { $('pod-row').loadPods(users); $('login-header-bar').showGuestButton = showGuest; // On Desktop, #login-header-bar has a shadow if there are 8+ profiles. if (Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER) $('login-header-bar').classList.toggle('shadow', users.length > 8); }, /** * Runs app with a given id from the list of loaded apps. * @param {!string} app_id of an app to run. * @param {boolean=} opt_diagnostic_mode Whether to run the app in * diagnostic mode. Default is false. */ runAppForTesting: function(app_id, opt_diagnostic_mode) { $('pod-row').findAndRunAppForTesting(app_id, opt_diagnostic_mode); }, /** * Adds given apps to the pod row. * @param {array} apps Array of apps. */ setApps: function(apps) { $('pod-row').setApps(apps); }, /** * Sets the flag of whether app pods should be visible. * @param {boolean} shouldShowApps Whether to show app pods. */ setShouldShowApps: function(shouldShowApps) { $('pod-row').setShouldShowApps(shouldShowApps); }, /** * Shows the given kiosk app error message. * @param {!string} message Error message to show. */ showAppError: function(message) { // TODO(nkostylev): Figure out a way to show kiosk app launch error // pointing to the kiosk app pod. /** @const */ var BUBBLE_PADDING = 12; $('bubble').showTextForElement($('pod-row'), message, cr.ui.Bubble.Attachment.BOTTOM, $('pod-row').offsetWidth / 2, BUBBLE_PADDING); }, /** * Updates current image of a user. * @param {string} username User for which to update the image. */ updateUserImage: function(username) { $('pod-row').updateUserImage(username); }, /** * Updates Caps Lock state (for Caps Lock hint in password input field). * @param {boolean} enabled Whether Caps Lock is on. */ setCapsLockState: function(enabled) { $('pod-row').classList.toggle('capslock-on', enabled); }, /** * Enforces focus on user pod of locked user. */ forceLockedUserPodFocus: function() { var row = $('pod-row'); if (row.lockedPod) row.focusPod(row.lockedPod, true); }, /** * Remove given user from pod row if it is there. * @param {string} user name. */ removeUser: function(username) { $('pod-row').removeUserPod(username); }, /** * Displays a banner containing |message|. If the banner is already present * this function updates the message in the banner. This function is used * by the chrome.screenlockPrivate.showMessage API. * @param {string} message Text to be displayed or empty to hide the banner. */ showBannerMessage: function(message) { var banner = $('signin-banner'); banner.textContent = message; banner.classList.toggle('message-set', !!message); }, /** * Shows a custom icon in the user pod of |username|. This function * is used by the chrome.screenlockPrivate API. * @param {string} username Username of pod to add button * @param {!{id: !string, * hardlockOnClick: boolean, * isTrialRun: boolean, * tooltip: ({text: string, autoshow: boolean} | undefined)}} icon * The icon parameters. */ showUserPodCustomIcon: function(username, icon) { $('pod-row').showUserPodCustomIcon(username, icon); }, /** * Hides the custom icon in the user pod of |username| added by * showUserPodCustomIcon(). This function is used by the * chrome.screenlockPrivate API. * @param {string} username Username of pod to remove button */ hideUserPodCustomIcon: function(username) { $('pod-row').hideUserPodCustomIcon(username); }, /** * Set a fingerprint icon in the user pod of |username|. * @param {string} username Username of the selected user * @param {number} state Fingerprint unlock state */ setUserPodFingerprintIcon: function(username, state) { $('pod-row').setUserPodFingerprintIcon(username, state); }, /** * Removes the fingerprint icon in the user pod of |username|. * @param {string} username Username of the selected user. */ removeUserPodFingerprintIcon: function(username) { $('pod-row').removeUserPodFingerprintIcon(username); }, /** * Sets the authentication type used to authenticate the user. * @param {string} username Username of selected user * @param {number} authType Authentication type, must be a valid value in * the AUTH_TYPE enum in user_pod_row.js. * @param {string} value The initial value to use for authentication. */ setAuthType: function(username, authType, value) { $('pod-row').setAuthType(username, authType, value); }, /** * Sets the state of tablet mode. * @param {boolean} isTabletModeEnabled true if the mode is on. */ setTabletModeState: function(isTabletModeEnabled) { $('pod-row').setTabletModeState(isTabletModeEnabled); }, /** * Enables or disables the pin keyboard for the given user. This may change * pin keyboard visibility. * @param {!string} user * @param {boolean} enabled */ setPinEnabledForUser: function(user, enabled) { $('pod-row').setPinEnabled(user, enabled); }, /** * Updates the display name shown on a public session pod. * @param {string} userID The user ID of the public session * @param {string} displayName The new display name */ setPublicSessionDisplayName: function(userID, displayName) { $('pod-row').setPublicSessionDisplayName(userID, displayName); }, /** * Updates the list of locales available for a public session. * @param {string} userID The user ID of the public session * @param {!Object} locales The list of available locales * @param {string} defaultLocale The locale to select by default * @param {boolean} multipleRecommendedLocales Whether |locales| contains * two or more recommended locales */ setPublicSessionLocales: function(userID, locales, defaultLocale, multipleRecommendedLocales) { $('pod-row').setPublicSessionLocales(userID, locales, defaultLocale, multipleRecommendedLocales); }, /** * Updates the list of available keyboard layouts for a public session pod. * @param {string} userID The user ID of the public session * @param {string} locale The locale to which this list of keyboard layouts * applies * @param {!Object} list List of available keyboard layouts */ setPublicSessionKeyboardLayouts: function(userID, locale, list) { $('pod-row').setPublicSessionKeyboardLayouts(userID, locale, list); }, /** * Updates UI based on the provided lock screen apps state. * * @param {LOCK_SCREEN_APPS_STATE} state The current lock screen apps state. */ setLockScreenAppsState: function(state) { if (Oobe.getInstance().displayType != DISPLAY_TYPE.LOCK || state == this.lockScreenAppsState_) { return; } this.lockScreenAppsState_ = state; $('login-header-bar').lockScreenAppsState = state; // When an lock screen app window is in background - i.e. visible behind // the lock screen UI - dim the lock screen background, so it's more // noticeable that the app widow in background is not actionable. $('background').classList.toggle( 'dimmed-background', state == LOCK_SCREEN_APPS_STATE.BACKGROUND); if (state === LOCK_SCREEN_APPS_STATE.FOREGROUND) $('pod-row').clearFocusedPod(); }, /** * Handles clicks on the document which displays the account picker UI. * If the click event target is outer container - i.e. background portion of * UI with no other UI elements, and lock screen apps are in background, a * request is issued to chrome to move lock screen apps to foreground. * @param {Event} event The click event. */ handleOwnerDocClick_: function(event) { if (this.lockScreenAppsState_ != LOCK_SCREEN_APPS_STATE.BACKGROUND || event.target != $('outer-container')) { return; } chrome.send('setLockScreenAppsState', [LOCK_SCREEN_APPS_STATE.FOREGROUND]); event.preventDefault(); event.stopPropagation(); }, }; }); // // 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 User pod row implementation. */ cr.define('login', function() { /** * Number of displayed columns depending on user pod count. * @type {Array} * @const */ var COLUMNS = [0, 1, 2, 3, 4, 5, 4, 4, 4, 5, 5, 6, 6, 5, 5, 6, 6, 6, 6]; /** * Mapping between number of columns in pod-row and margin between user pods * for such layout. * @type {Array} * @const */ var MARGIN_BY_COLUMNS = [undefined, 40, 40, 40, 40, 40, 12]; /** * Mapping between number of columns in the desktop pod-row and margin * between user pods for such layout. * @type {Array} * @const */ var DESKTOP_MARGIN_BY_COLUMNS = [undefined, 32, 32, 32, 32, 32, 32]; /** * Maximal number of columns currently supported by pod-row. * @type {number} * @const */ var MAX_NUMBER_OF_COLUMNS = 6; /** * Maximal number of rows if sign-in banner is displayed alonside. * @type {number} * @const */ var MAX_NUMBER_OF_ROWS_UNDER_SIGNIN_BANNER = 2; /** * Variables used for pod placement processing. Width and height should be * synced with computed CSS sizes of pods. */ var CROS_POD_WIDTH = 180; var DESKTOP_POD_WIDTH = 180; var MD_DESKTOP_POD_WIDTH = 160; var PUBLIC_EXPANDED_BASIC_WIDTH = 500; var PUBLIC_EXPANDED_ADVANCED_WIDTH = 610; var CROS_POD_HEIGHT = 213; var DESKTOP_POD_HEIGHT = 226; var MD_DESKTOP_POD_HEIGHT = 200; var POD_ROW_PADDING = 10; var DESKTOP_ROW_PADDING = 32; var CUSTOM_ICON_CONTAINER_SIZE = 40; var CROS_PIN_POD_HEIGHT = 417; /** * Minimal padding between user pod and virtual keyboard. * @type {number} * @const */ var USER_POD_KEYBOARD_MIN_PADDING = 20; /** * Maximum time for which the pod row remains hidden until all user images * have been loaded. * @type {number} * @const */ var POD_ROW_IMAGES_LOAD_TIMEOUT_MS = 3000; /** * Public session help topic identifier. * @type {number} * @const */ var HELP_TOPIC_PUBLIC_SESSION = 3041033; /** * Tab order for user pods. Update these when adding new controls. * @enum {number} * @const */ var UserPodTabOrder = { POD_INPUT: 1, // Password input field, Action box menu button and // the pod itself. PIN_KEYBOARD: 2, // Pin keyboard below the password input field. POD_CUSTOM_ICON: 3, // Pod custom icon next to password input field. HEADER_BAR: 4, // Buttons on the header bar (Shutdown, Add User). POD_MENU_ITEM: 5 // User pad menu items (User info, Remove user). }; /** * Supported authentication types. Keep in sync with the enum in * components/proximity_auth/public/interfaces/auth_type.mojom * @enum {number} * @const */ var AUTH_TYPE = { OFFLINE_PASSWORD: 0, ONLINE_SIGN_IN: 1, NUMERIC_PIN: 2, USER_CLICK: 3, EXPAND_THEN_USER_CLICK: 4, FORCE_OFFLINE_PASSWORD: 5 }; /** * Names of authentication types. */ var AUTH_TYPE_NAMES = { 0: 'offlinePassword', 1: 'onlineSignIn', 2: 'numericPin', 3: 'userClick', 4: 'expandThenUserClick', 5: 'forceOfflinePassword' }; /** * Supported fingerprint unlock states. * @enum {number} * @const */ var FINGERPRINT_STATES = { HIDDEN: 0, DEFAULT: 1, SIGNIN: 2, FAILED: 3, }; /** * The fingerprint states to classes mapping. * {@code state} properties indicate current fingerprint unlock state. * {@code class} properties are CSS classes used to set the icons' background * and password placeholder color. * @const {Array<{type: !number, class: !string}>} */ var FINGERPRINT_STATES_MAPPING = [ {state: FINGERPRINT_STATES.HIDDEN, class: 'hidden'}, {state: FINGERPRINT_STATES.DEFAULT, class: 'default'}, {state: FINGERPRINT_STATES.SIGNIN, class: 'signin'}, {state: FINGERPRINT_STATES.FAILED, class: 'failed'} ]; // Supported multi-profile user behavior values. // Keep in sync with the enum in login_user_info.mojom var MULTI_PROFILE_USER_BEHAVIOR = { UNRESTRICTED: 0, PRIMARY_ONLY: 1, NOT_ALLOWED: 2, OWNER_PRIMARY_ONLY: 3 }; // Focus and tab order are organized as follows: // // (1) all user pods have tab index 1 so they are traversed first; // (2) when a user pod is activated, its tab index is set to -1 and its // main input field gets focus and tab index 1; // (3) if user pod custom icon is interactive, it has tab index 2 so it // follows the input. // (4) buttons on the header bar have tab index 3 so they follow the custom // icon, or user pod if custom icon is not interactive; // (5) Action box buttons have tab index 4 and follow header bar buttons; // (6) lastly, focus jumps to the Status Area and back to user pods. // // 'Focus' event is handled by a capture handler for the whole document // and in some cases 'mousedown' event handlers are used instead of 'click' // handlers where it's necessary to prevent 'focus' event from being fired. /** * Helper function to remove a class from given element. * @param {!HTMLElement} el Element whose class list to change. * @param {string} cl Class to remove. */ function removeClass(el, cl) { el.classList.remove(cl); } /** * Creates a user pod. * @constructor * @extends {HTMLDivElement} */ var UserPod = cr.ui.define(function() { var node = $('user-pod-template').cloneNode(true); node.removeAttribute('id'); return node; }); /** * Stops event propagation from the any user pod child element. * @param {Event} e Event to handle. */ function stopEventPropagation(e) { // Prevent default so that we don't trigger a 'focus' event. e.preventDefault(); e.stopPropagation(); } /** * Creates an element for custom icon shown in a user pod next to the input * field. * @constructor * @extends {HTMLDivElement} */ var UserPodCustomIcon = cr.ui.define(function() { var node = document.createElement('div'); node.classList.add('custom-icon-container'); node.hidden = true; // Create the actual icon element and add it as a child to the container. var iconNode = document.createElement('div'); iconNode.classList.add('custom-icon'); node.appendChild(iconNode); return node; }); /** * The supported user pod custom icons. * {@code id} properties should be in sync with values set by C++ side. * {@code class} properties are CSS classes used to set the icons' background. * @const {Array<{id: !string, class: !string}>} */ UserPodCustomIcon.ICONS = [ {id: 'locked', class: 'custom-icon-locked'}, {id: 'locked-to-be-activated', class: 'custom-icon-locked-to-be-activated'}, {id: 'locked-with-proximity-hint', class: 'custom-icon-locked-with-proximity-hint'}, {id: 'unlocked', class: 'custom-icon-unlocked'}, {id: 'hardlocked', class: 'custom-icon-hardlocked'}, {id: 'spinner', class: 'custom-icon-spinner'} ]; /** * The hover state for the icon. When user hovers over the icon, a tooltip * should be shown after a short delay. This enum is used to keep track of * the tooltip status related to hover state. * @enum {string} */ UserPodCustomIcon.HoverState = { /** The user is not hovering over the icon. */ NO_HOVER: 'no_hover', /** The user is hovering over the icon but the tooltip is not activated. */ HOVER: 'hover', /** * User is hovering over the icon and the tooltip is activated due to the * hover state (which happens with delay after user starts hovering). */ HOVER_TOOLTIP: 'hover_tooltip' }; /** * If the icon has a tooltip that should be automatically shown, the tooltip * is shown even when there is no user action (i.e. user is not hovering over * the icon), after a short delay. The tooltip should be hidden after some * time. Note that the icon will not be considered autoshown if it was * previously shown as a result of the user action. * This enum is used to keep track of this state. * @enum {string} */ UserPodCustomIcon.TooltipAutoshowState = { /** The tooltip should not be or was not automatically shown. */ DISABLED: 'disabled', /** * The tooltip should be automatically shown, but the timeout for showing * the tooltip has not yet passed. */ ENABLED: 'enabled', /** The tooltip was automatically shown. */ ACTIVE : 'active' }; UserPodCustomIcon.prototype = { __proto__: HTMLDivElement.prototype, /** * The id of the icon being shown. * @type {string} * @private */ iconId_: '', /** * A reference to the timeout for updating icon hover state. Non-null * only if there is an active timeout. * @type {?number} * @private */ updateHoverStateTimeout_: null, /** * A reference to the timeout for updating icon tooltip autoshow state. * Non-null only if there is an active timeout. * @type {?number} * @private */ updateTooltipAutoshowStateTimeout_: null, /** * Callback for click and 'Enter' key events that gets set if the icon is * interactive. * @type {?function()} * @private */ actionHandler_: null, /** * The current tooltip state. * @type {{active: function(): boolean, * autoshow: !UserPodCustomIcon.TooltipAutoshowState, * hover: !UserPodCustomIcon.HoverState, * text: string}} * @private */ tooltipState_: { /** * Utility method for determining whether the tooltip is active, either as * a result of hover state or being autoshown. * @return {boolean} */ active: function() { return this.autoshow == UserPodCustomIcon.TooltipAutoshowState.ACTIVE || this.hover == UserPodCustomIcon.HoverState.HOVER_TOOLTIP; }, /** * @type {!UserPodCustomIcon.TooltipAutoshowState} */ autoshow: UserPodCustomIcon.TooltipAutoshowState.DISABLED, /** * @type {!UserPodCustomIcon.HoverState} */ hover: UserPodCustomIcon.HoverState.NO_HOVER, /** * The tooltip text. * @type {string} */ text: '' }, /** @override */ decorate: function() { this.iconElement.addEventListener( 'mouseover', this.updateHoverState_.bind(this, UserPodCustomIcon.HoverState.HOVER)); this.iconElement.addEventListener( 'mouseout', this.updateHoverState_.bind(this, UserPodCustomIcon.HoverState.NO_HOVER)); this.iconElement.addEventListener('mousedown', this.handleMouseDown_.bind(this)); this.iconElement.addEventListener('click', this.handleClick_.bind(this)); this.iconElement.addEventListener('keydown', this.handleKeyDown_.bind(this)); // When the icon is focused using mouse, there should be no outline shown. // Preventing default mousedown event accomplishes this. this.iconElement.addEventListener('mousedown', function(e) { e.preventDefault(); }); }, /** * Getter for the icon element's div. * @return {HTMLDivElement} */ get iconElement() { return this.querySelector('.custom-icon'); }, /** * Updates the icon element class list to properly represent the provided * icon. * @param {!string} id The id of the icon that should be shown. Should be * one of the ids listed in {@code UserPodCustomIcon.ICONS}. */ setIcon: function(id) { this.iconId_ = id; UserPodCustomIcon.ICONS.forEach(function(icon) { this.iconElement.classList.toggle(icon.class, id == icon.id); }, this); }, /** * Sets the ARIA label for the icon. * @param {!string} ariaLabel */ setAriaLabel: function(ariaLabel) { this.iconElement.setAttribute('aria-label', ariaLabel); }, /** * Shows the icon. */ show: function() { // Show the icon if the current iconId is valid. var validIcon = false; UserPodCustomIcon.ICONS.forEach(function(icon) { validIcon = validIcon || this.iconId_ == icon.id; }, this); this.hidden = validIcon ? false : true; }, /** * Updates the icon tooltip. If {@code autoshow} parameter is set the * tooltip is immediatelly shown. If tooltip text is not set, the method * ensures the tooltip gets hidden. If tooltip is shown prior to this call, * it remains shown, but the tooltip text is updated. * @param {!{text: string, autoshow: boolean}} tooltip The tooltip * parameters. */ setTooltip: function(tooltip) { this.iconElement.classList.toggle('icon-with-tooltip', !!tooltip.text); this.updateTooltipAutoshowState_( tooltip.autoshow ? UserPodCustomIcon.TooltipAutoshowState.ENABLED : UserPodCustomIcon.TooltipAutoshowState.DISABLED); this.tooltipState_.text = tooltip.text; this.updateTooltip_(); }, /** * Sets up icon tabIndex attribute and handler for click and 'Enter' key * down events. * @param {?function()} callback If icon should be interactive, the * function to get called on click and 'Enter' key down events. Should * be null to make the icon non interactive. */ setInteractive: function(callback) { this.iconElement.classList.toggle('interactive-custom-icon', !!callback); // Update tabIndex property if needed. if (!!this.actionHandler_ != !!callback) { if (callback) { this.iconElement.setAttribute('tabIndex', UserPodTabOrder.POD_CUSTOM_ICON); } else { this.iconElement.removeAttribute('tabIndex'); } } // Set the new action handler. this.actionHandler_ = callback; }, /** * Hides the icon and cleans its state. */ hide: function() { this.hideTooltip_(); this.clearUpdateHoverStateTimeout_(); this.clearUpdateTooltipAutoshowStateTimeout_(); this.setInteractive(null); this.hidden = true; }, /** * Clears timeout for showing a tooltip if one is set. Used to cancel * showing the tooltip when the user starts typing the password. */ cancelDelayedTooltipShow: function() { this.updateTooltipAutoshowState_( UserPodCustomIcon.TooltipAutoshowState.DISABLED); this.clearUpdateHoverStateTimeout_(); }, /** * Handles mouse down event in the icon element. * @param {Event} e The mouse down event. * @private */ handleMouseDown_: function(e) { this.updateHoverState_(UserPodCustomIcon.HoverState.NO_HOVER); this.updateTooltipAutoshowState_( UserPodCustomIcon.TooltipAutoshowState.DISABLED); // Stop the event propagation so in the case the click ends up on the // user pod (outside the custom icon) auth is not attempted. stopEventPropagation(e); }, /** * Handles click event on the icon element. No-op if * {@code this.actionHandler_} is not set. * @param {Event} e The click event. * @private */ handleClick_: function(e) { if (!this.actionHandler_) return; this.actionHandler_(); stopEventPropagation(e); }, /** * Handles key down event on the icon element. Only 'Enter' key is handled. * No-op if {@code this.actionHandler_} is not set. * @param {Event} e The key down event. * @private */ handleKeyDown_: function(e) { if (!this.actionHandler_ || e.key != 'Enter') return; this.actionHandler_(e); stopEventPropagation(e); }, /** * Changes the tooltip hover state and updates tooltip visibility if needed. * @param {!UserPodCustomIcon.HoverState} state * @private */ updateHoverState_: function(state) { this.clearUpdateHoverStateTimeout_(); this.sanitizeTooltipStateIfBubbleHidden_(); if (state == UserPodCustomIcon.HoverState.HOVER) { if (this.tooltipState_.active()) { this.tooltipState_.hover = UserPodCustomIcon.HoverState.HOVER_TOOLTIP; } else { this.updateHoverStateSoon_( UserPodCustomIcon.HoverState.HOVER_TOOLTIP); } return; } if (state != UserPodCustomIcon.HoverState.NO_HOVER && state != UserPodCustomIcon.HoverState.HOVER_TOOLTIP) { console.error('Invalid hover state ' + state); return; } this.tooltipState_.hover = state; this.updateTooltip_(); }, /** * Sets up a timeout for updating icon hover state. * @param {!UserPodCustomIcon.HoverState} state * @private */ updateHoverStateSoon_: function(state) { if (this.updateHoverStateTimeout_) clearTimeout(this.updateHoverStateTimeout_); this.updateHoverStateTimeout_ = setTimeout(this.updateHoverState_.bind(this, state), 1000); }, /** * Clears a timeout for updating icon hover state if there is one set. * @private */ clearUpdateHoverStateTimeout_: function() { if (this.updateHoverStateTimeout_) { clearTimeout(this.updateHoverStateTimeout_); this.updateHoverStateTimeout_ = null; } }, /** * Changes the tooltip autoshow state and changes tooltip visibility if * needed. * @param {!UserPodCustomIcon.TooltipAutoshowState} state * @private */ updateTooltipAutoshowState_: function(state) { this.clearUpdateTooltipAutoshowStateTimeout_(); this.sanitizeTooltipStateIfBubbleHidden_(); if (state == UserPodCustomIcon.TooltipAutoshowState.DISABLED) { if (this.tooltipState_.autoshow != state) { this.tooltipState_.autoshow = state; this.updateTooltip_(); } return; } if (this.tooltipState_.active()) { if (this.tooltipState_.autoshow != UserPodCustomIcon.TooltipAutoshowState.ACTIVE) { this.tooltipState_.autoshow = UserPodCustomIcon.TooltipAutoshowState.DISABLED; } else { // If the tooltip is already automatically shown, the timeout for // removing it should be reset. this.updateTooltipAutoshowStateSoon_( UserPodCustomIcon.TooltipAutoshowState.DISABLED); } return; } if (state == UserPodCustomIcon.TooltipAutoshowState.ENABLED) { this.updateTooltipAutoshowStateSoon_( UserPodCustomIcon.TooltipAutoshowState.ACTIVE); } else if (state == UserPodCustomIcon.TooltipAutoshowState.ACTIVE) { this.updateTooltipAutoshowStateSoon_( UserPodCustomIcon.TooltipAutoshowState.DISABLED); } this.tooltipState_.autoshow = state; this.updateTooltip_(); }, /** * Sets up a timeout for updating tooltip autoshow state. * @param {!UserPodCustomIcon.TooltipAutoshowState} state * @private */ updateTooltipAutoshowStateSoon_: function(state) { if (this.updateTooltipAutoshowStateTimeout_) clearTimeout(this.updateTooltupAutoshowStateTimeout_); var timeout = state == UserPodCustomIcon.TooltipAutoshowState.DISABLED ? 5000 : 1000; this.updateTooltipAutoshowStateTimeout_ = setTimeout(this.updateTooltipAutoshowState_.bind(this, state), timeout); }, /** * Clears the timeout for updating tooltip autoshow state if one is set. * @private */ clearUpdateTooltipAutoshowStateTimeout_: function() { if (this.updateTooltipAutoshowStateTimeout_) { clearTimeout(this.updateTooltipAutoshowStateTimeout_); this.updateTooltipAutoshowStateTimeout_ = null; } }, /** * If tooltip bubble is hidden, this makes sure that hover and tooltip * autoshow states are not the ones that imply an active tooltip. * Used to handle a case where the tooltip bubble is hidden by an event that * does not update one of the states (e.g. click outside the pod will not * update tooltip autoshow state). Should be called before making * tooltip state updates. * @private */ sanitizeTooltipStateIfBubbleHidden_: function() { if (!$('bubble').hidden) return; if (this.tooltipState_.hover == UserPodCustomIcon.HoverState.HOVER_TOOLTIP && this.tooltipState_.text) { this.tooltipState_.hover = UserPodCustomIcon.HoverState.NO_HOVER; this.clearUpdateHoverStateTimeout_(); } if (this.tooltipState_.autoshow == UserPodCustomIcon.TooltipAutoshowState.ACTIVE) { this.tooltipState_.autoshow = UserPodCustomIcon.TooltipAutoshowState.DISABLED; this.clearUpdateTooltipAutoshowStateTimeout_(); } }, /** * Returns whether the user pod to which the custom icon belongs is focused. * @return {boolean} * @private */ isParentPodFocused_: function() { if ($('account-picker').hidden) return false; var parentPod = this.parentNode; while (parentPod && !parentPod.classList.contains('pod')) parentPod = parentPod.parentNode; return parentPod && parentPod.parentNode.isFocused(parentPod); }, /** * Depending on {@code this.tooltipState_}, it updates tooltip visibility * and text. * @private */ updateTooltip_: function() { if (this.hidden || !this.isParentPodFocused_()) return; if (!this.tooltipState_.active() || !this.tooltipState_.text) { this.hideTooltip_(); return; } // Show the tooltip bubble. var bubbleContent = document.createElement('div'); bubbleContent.textContent = this.tooltipState_.text; /** @const */ var BUBBLE_OFFSET = CUSTOM_ICON_CONTAINER_SIZE / 2; // TODO(tengs): Introduce a special reauth state for the account picker, // instead of showing the tooltip bubble here (crbug.com/409427). /** @const */ var BUBBLE_PADDING = 8 + (this.iconId_ ? 0 : 23); $('bubble').showContentForElement(this, cr.ui.Bubble.Attachment.LEFT, bubbleContent, BUBBLE_OFFSET, BUBBLE_PADDING); }, /** * Hides the tooltip. * @private */ hideTooltip_: function() { $('bubble').hideForElement(this); } }; /** * Unique salt added to user image URLs to prevent caching. Dictionary with * user names as keys. * @type {Object} */ UserPod.userImageSalt_ = {}; UserPod.prototype = { __proto__: HTMLDivElement.prototype, /** * Whether click on the pod can issue a user click auth attempt. The * attempt can be issued iff the pod was focused when the click * started (i.e. on mouse down event). * @type {boolean} * @private */ userClickAuthAllowed_: false, /** * Whether the user has recently authenticated with fingerprint. * @type {boolean} * @private */ fingerprintAuthenticated_: false, /** * True iff the pod can display the pin keyboard. The pin keyboard may not * always be displayed even if this is true, ie, if the virtual keyboard is * also being displayed. */ pinEnabled: false, /** @override */ decorate: function() { this.tabIndex = UserPodTabOrder.POD_INPUT; this.actionBoxAreaElement.tabIndex = UserPodTabOrder.POD_INPUT; this.addEventListener('keydown', this.handlePodKeyDown_.bind(this)); this.addEventListener('click', this.handleClickOnPod_.bind(this)); this.addEventListener('mousedown', this.handlePodMouseDown_.bind(this)); if (this.pinKeyboard) { this.pinKeyboard.passwordElement = this.passwordElement; this.pinKeyboard.addEventListener('pin-change', this.handleInputChanged_.bind(this)); this.pinKeyboard.tabIndex = UserPodTabOrder.PIN_KEYBOARD; } this.actionBoxAreaElement.addEventListener('mousedown', stopEventPropagation); this.actionBoxAreaElement.addEventListener('click', this.handleActionAreaButtonClick_.bind(this)); this.actionBoxAreaElement.addEventListener('keydown', this.handleActionAreaButtonKeyDown_.bind(this)); this.actionBoxAreaElement.addEventListener('focus', () => { this.isActionBoxMenuActive = false; }); this.actionBoxMenuTitleElement.addEventListener('keydown', this.handleMenuTitleElementKeyDown_.bind(this)); this.actionBoxMenuTitleElement.addEventListener('blur', this.handleMenuTitleElementBlur_.bind(this)); this.actionBoxMenuRemoveElement.addEventListener('click', this.handleRemoveCommandClick_.bind(this)); this.actionBoxMenuRemoveElement.addEventListener('keydown', this.handleRemoveCommandKeyDown_.bind(this)); this.actionBoxMenuRemoveElement.addEventListener('blur', this.handleRemoveCommandBlur_.bind(this)); this.actionBoxRemoveUserWarningButtonElement.addEventListener('click', this.handleRemoveUserConfirmationClick_.bind(this)); this.actionBoxRemoveUserWarningButtonElement.addEventListener('keydown', this.handleRemoveUserConfirmationKeyDown_.bind(this)); if (this.fingerprintIconElement) { this.fingerprintIconElement.addEventListener( 'mouseover', this.handleFingerprintIconMouseOver_.bind(this)); this.fingerprintIconElement.addEventListener( 'mouseout', this.handleFingerprintIconMouseOut_.bind(this)); this.fingerprintIconElement.addEventListener( 'mousedown', stopEventPropagation); } var customIcon = this.customIconElement; customIcon.parentNode.replaceChild(new UserPodCustomIcon(), customIcon); }, /** * Initializes the pod after its properties set and added to a pod row. */ initialize: function() { this.passwordElement.addEventListener('keydown', this.parentNode.handleKeyDown.bind(this.parentNode)); this.passwordElement.addEventListener('keypress', this.handlePasswordKeyPress_.bind(this)); this.passwordElement.addEventListener('input', this.handleInputChanged_.bind(this)); if (this.submitButton) { this.submitButton.addEventListener('click', this.handleSubmitButtonClick_.bind(this)); } this.imageElement.addEventListener('load', this.parentNode.handlePodImageLoad.bind(this.parentNode, this)); var initialAuthType = this.user.initialAuthType || AUTH_TYPE.OFFLINE_PASSWORD; this.setAuthType(initialAuthType, null); if (this.user.isActiveDirectory) this.setAttribute('is-active-directory', ''); this.userClickAuthAllowed_ = false; // Lazy load the assets needed for the polymer submit button. var isLockScreen = (Oobe.getInstance().displayType == DISPLAY_TYPE.LOCK); if (cr.isChromeOS && isLockScreen && !cr.ui.login.ResourceLoader.alreadyLoadedAssets( 'custom-elements-user-pod')) { cr.ui.login.ResourceLoader.registerAssets({ id: 'custom-elements-user-pod', html: [{ url: 'custom_elements_user_pod.html' }] }); cr.ui.login.ResourceLoader.loadAssetsOnIdle('custom-elements-user-pod'); } }, /** * Whether the user pod is disabled. * @type {boolean} */ disabled_: false, get disabled() { return this.disabled_; }, set disabled(value) { this.disabled_ = value; this.querySelectorAll('button,input').forEach(function(element) { element.disabled = value }); // Special handling for submit button - the submit button should be // enabled only if there is the password value set. var submitButton = this.submitButton; if (submitButton) submitButton.disabled = value || !this.passwordElement.value; }, /** * Resets tab order for pod elements to its initial state. */ resetTabOrder: function() { // Note: the |mainInput| can be the pod itself. this.mainInput.tabIndex = -1; this.tabIndex = UserPodTabOrder.POD_INPUT; }, /** * Handles keypress event (i.e. any textual input) on password input. * @param {Event} e Keypress Event object. * @private */ handlePasswordKeyPress_: function(e) { // When tabbing from the system tray a tab key press is received. Suppress // this so as not to type a tab character into the password field. if (e.keyCode == 9) { e.preventDefault(); return; } this.customIconElement.cancelDelayedTooltipShow(); }, /** * Handles a click event on submit button. * @param {Event} e Click event. */ handleSubmitButtonClick_: function(e) { this.parentNode.setActivatedPod(this, e); }, /** * Top edge margin number of pixels. * @type {?number} */ set top(top) { this.style.top = cr.ui.toCssPx(top); }, /** * Top edge margin number of pixels. */ get top() { return parseInt(this.style.top); }, /** * Left edge margin number of pixels. * @type {?number} */ set left(left) { this.style.left = cr.ui.toCssPx(left); }, /** * Left edge margin number of pixels. */ get left() { return parseInt(this.style.left); }, /** * Height number of pixels. */ get height() { return this.offsetHeight; }, /** * Gets image element. * @type {!HTMLImageElement} */ get imageElement() { return this.querySelector('.user-image'); }, /** * Gets animated image element. * @type {!HTMLImageElement} */ get animatedImageElement() { return this.querySelector('.user-image.animated-image'); }, /** * Gets name element. * @type {!HTMLDivElement} */ get nameElement() { return this.querySelector('.name'); }, /** * Gets reauth name hint element. * @type {!HTMLDivElement} */ get reauthNameHintElement() { return this.querySelector('.reauth-name-hint'); }, /** * Gets the container holding the password field. * @type {!HTMLInputElement} */ get passwordEntryContainerElement() { return this.querySelector('.password-entry-container'); }, /** * Gets password field. * @type {!HTMLInputElement} */ get passwordElement() { return this.querySelector('.password'); }, /** * Gets submit button. * @type {!HTMLInputElement} */ get submitButton() { return this.querySelector('.submit-button'); }, /** * Gets the password label, which is used to show a message where the * password field is normally. * @type {!HTMLInputElement} */ get passwordLabelElement() { return this.querySelector('.password-label'); }, get pinContainer() { return this.querySelector('.pin-container'); }, /** * Gets the pin-keyboard of the pod. * @type {!HTMLElement} */ get pinKeyboard() { return this.querySelector('pin-keyboard'); }, /** * Gets user online sign in hint element. * @type {!HTMLDivElement} */ get reauthWarningElement() { return this.querySelector('.reauth-hint-container'); }, /** * Gets the container holding the launch app button. * @type {!HTMLButtonElement} */ get launchAppButtonContainerElement() { return this.querySelector('.launch-app-button-container'); }, /** * Gets launch app button. * @type {!HTMLButtonElement} */ get launchAppButtonElement() { return this.querySelector('.launch-app-button'); }, /** * Gets action box area. * @type {!HTMLInputElement} */ get actionBoxAreaElement() { return this.querySelector('.action-box-area'); }, /** * Gets user type icon area. * @type {!HTMLDivElement} */ get userTypeIconAreaElement() { return this.querySelector('.user-type-icon-area'); }, /** * Gets user type bubble like multi-profiles policy restriction message. * @type {!HTMLDivElement} */ get userTypeBubbleElement() { return this.querySelector('.user-type-bubble'); }, /** * Gets action box menu. * @type {!HTMLDivElement} */ get actionBoxMenu() { return this.querySelector('.action-box-menu'); }, /** * Gets action box menu title (user name and email). * @type {!HTMLDivElement} */ get actionBoxMenuTitleElement() { return this.querySelector('.action-box-menu-title'); }, /** * Gets action box menu title, user name item. * @type {!HTMLSpanElement} */ get actionBoxMenuTitleNameElement() { return this.querySelector('.action-box-menu-title-name'); }, /** * Gets action box menu title, user email item. * @type {!HTMLSpanElement} */ get actionBoxMenuTitleEmailElement() { return this.querySelector('.action-box-menu-title-email'); }, /** * Gets action box menu, remove user command item. * @type {!HTMLInputElement} */ get actionBoxMenuCommandElement() { return this.querySelector('.action-box-menu-remove-command'); }, /** * Gets action box menu, remove user command item div. * @type {!HTMLInputElement} */ get actionBoxMenuRemoveElement() { return this.querySelector('.action-box-menu-remove'); }, /** * Gets action box menu, remove user command item div. * @type {!HTMLInputElement} */ get actionBoxRemoveUserWarningElement() { return this.querySelector('.action-box-remove-user-warning'); }, /** * Gets action box menu, remove user command item div. * @type {!HTMLInputElement} */ get actionBoxRemoveUserWarningButtonElement() { return this.querySelector('.remove-warning-button'); }, /** * Gets the custom icon. This icon is normally hidden, but can be shown * using the chrome.screenlockPrivate API. * @type {!HTMLDivElement} */ get customIconElement() { return this.querySelector('.custom-icon-container'); }, /** * Gets the elements used for statistics display. * @type {Object.} */ get statsMapElements() { return { 'BrowsingHistory': this.querySelector('.action-box-remove-user-warning-history'), 'Passwords': this.querySelector('.action-box-remove-user-warning-passwords'), 'Bookmarks': this.querySelector('.action-box-remove-user-warning-bookmarks'), 'Autofill': this.querySelector('.action-box-remove-user-warning-autofill') } }, /** * Gets the fingerprint icon area. * @type {!HTMLDivElement} */ get fingerprintIconElement() { return this.querySelector('.fingerprint-icon-container'); }, /** * Updates the user pod element. */ update: function() { var animatedImageSrc = 'chrome://userimage/' + this.user.username + '?id=' + UserPod.userImageSalt_[this.user.username]; this.imageElement.src = animatedImageSrc + '&frame=0'; this.animatedImageElement.src = animatedImageSrc; this.nameElement.textContent = this.user_.displayName; this.reauthNameHintElement.textContent = this.user_.displayName; this.classList.toggle('signed-in', this.user_.signedIn); if (this.isAuthTypeUserClick) this.passwordLabelElement.textContent = this.authValue; this.updateActionBoxArea(); this.passwordElement.setAttribute('aria-label', loadTimeData.getStringF( 'passwordFieldAccessibleName', this.user_.emailAddress)); this.customizeUserPodPerUserType(); }, updateActionBoxArea: function() { if (this.user_.publicAccount || this.user_.isApp) { this.actionBoxAreaElement.hidden = true; return; } this.actionBoxMenuRemoveElement.hidden = !this.user_.canRemove; this.actionBoxAreaElement.setAttribute( 'aria-label', loadTimeData.getStringF( 'podMenuButtonAccessibleName', this.user_.emailAddress)); this.actionBoxMenuRemoveElement.setAttribute( 'aria-label', loadTimeData.getString( 'podMenuRemoveItemAccessibleName')); this.actionBoxMenuTitleNameElement.textContent = this.user_.isOwner ? loadTimeData.getStringF('ownerUserPattern', this.user_.displayName) : this.user_.displayName; this.actionBoxMenuTitleEmailElement.textContent = this.user_.emailAddress; this.actionBoxMenuTitleEmailElement.hidden = this.user_.legacySupervisedUser; this.actionBoxMenuCommandElement.textContent = loadTimeData.getString('removeUser'); }, customizeUserPodPerUserType: function() { if (this.user_.childUser && !this.user_.isDesktopUser) { this.setUserPodIconType('child'); } else if (this.user_.legacySupervisedUser && !this.user_.isDesktopUser) { this.setUserPodIconType('legacySupervised'); this.classList.add('legacy-supervised'); } else if (this.multiProfilesPolicyApplied) { // Mark user pod as not focusable which in addition to the grayed out // filter makes it look in disabled state. this.classList.add('multiprofiles-policy-applied'); this.setUserPodIconType('policy'); if (this.user.multiProfilesPolicy == MULTI_PROFILE_USER_BEHAVIOR.PRIMARY_ONLY) { this.querySelector('.mp-policy-primary-only-msg').hidden = false; } else if (this.user.multiProfilesPolicy == MULTI_PROFILE_USER_BEHAVIOR.OWNER_PRIMARY_ONLY) { this.querySelector('.mp-owner-primary-only-msg').hidden = false; } else { this.querySelector('.mp-policy-not-allowed-msg').hidden = false; } } else if (this.user_.isApp) { this.setUserPodIconType('app'); } }, isPinReady: function() { return this.pinKeyboard && this.pinKeyboard.offsetHeight > 0; }, set showError(visible) { if (this.submitButton) this.submitButton.classList.toggle('error-shown', visible); }, updatePinClass_: function(element, enable) { element.classList.toggle('pin-enabled', enable); element.classList.toggle('pin-disabled', !enable); }, setPinVisibility: function(visible) { if (this.isPinShown() == visible) return; // Do not show pin if virtual keyboard is there. if (visible && Oobe.getInstance().virtualKeyboardShown) return; // Do not show pin keyboard if the pod does not have pin enabled. if (visible && !this.pinEnabled) return; var elements = this.getElementsByClassName('pin-tag'); for (var i = 0; i < elements.length; ++i) this.updatePinClass_(elements[i], visible); this.updatePinClass_(this, visible); // Set the focus to the input element after showing/hiding pin keyboard. this.mainInput.focus(); // Change the password placeholder based on pin keyboard visibility. this.passwordElement.placeholder = loadTimeData.getString(visible ? 'pinKeyboardPlaceholderPinPassword' : 'passwordHint'); }, isPinShown: function() { return this.classList.contains('pin-enabled'); }, setUserPodIconType: function(userTypeClass) { this.userTypeIconAreaElement.classList.add(userTypeClass); this.userTypeIconAreaElement.hidden = false; }, isFingerprintIconShown: function() { return this.fingerprintIconElement && !this.fingerprintIconElement.hidden; }, /** * The user that this pod represents. * @type {!Object} */ user_: undefined, get user() { return this.user_; }, set user(userDict) { this.user_ = userDict; this.update(); }, /** * Returns true if multi-profiles sign in is currently active and this * user pod is restricted per policy. * @type {boolean} */ get multiProfilesPolicyApplied() { var isMultiProfilesUI = (Oobe.getInstance().displayType == DISPLAY_TYPE.USER_ADDING); return isMultiProfilesUI && !this.user_.isMultiProfilesAllowed; }, /** * Gets main input element. * @type {(HTMLButtonElement|HTMLInputElement)} */ get mainInput() { if (this.isAuthTypePassword) { return this.passwordElement; } else if (this.isAuthTypeOnlineSignIn) { return this; } else if (this.isAuthTypeUserClick) { return this.passwordLabelElement; } }, /** * Whether action box button is in active state. * @type {boolean} */ get isActionBoxMenuActive() { return this.actionBoxAreaElement.classList.contains('active'); }, set isActionBoxMenuActive(active) { if (active == this.isActionBoxMenuActive) return; if (active) { this.actionBoxMenuRemoveElement.hidden = !this.user_.canRemove; this.actionBoxRemoveUserWarningElement.hidden = true; // Clear focus first if another pod is focused. if (!this.parentNode.isFocused(this)) { this.parentNode.focusPod(undefined, true); this.actionBoxAreaElement.focus(); } // Hide user-type-bubble. this.userTypeBubbleElement.classList.remove('bubble-shown'); this.actionBoxAreaElement.classList.add('active'); // Invisible focus causes ChromeVox to read user name and email. this.actionBoxMenuTitleElement.tabIndex = UserPodTabOrder.POD_MENU_ITEM; this.actionBoxMenuTitleElement.focus(); // If the user pod is on either edge of the screen, then the menu // could be displayed partially ofscreen. this.actionBoxMenu.classList.remove('left-edge-offset'); this.actionBoxMenu.classList.remove('right-edge-offset'); var offsetLeft = cr.ui.login.DisplayManager.getOffset(this.actionBoxMenu).left; var menuWidth = this.actionBoxMenu.offsetWidth; if (offsetLeft < 0) this.actionBoxMenu.classList.add('left-edge-offset'); else if (offsetLeft + menuWidth > window.innerWidth) this.actionBoxMenu.classList.add('right-edge-offset'); } else { this.actionBoxAreaElement.classList.remove('active'); this.actionBoxAreaElement.classList.remove('menu-moved-up'); this.actionBoxMenu.classList.remove('menu-moved-up'); } }, /** * Whether action box button is in hovered state. * @type {boolean} */ get isActionBoxMenuHovered() { return this.actionBoxAreaElement.classList.contains('hovered'); }, set isActionBoxMenuHovered(hovered) { if (hovered == this.isActionBoxMenuHovered) return; if (hovered) { this.actionBoxAreaElement.classList.add('hovered'); this.classList.add('hovered'); } else { if (this.multiProfilesPolicyApplied) this.userTypeBubbleElement.classList.remove('bubble-shown'); this.actionBoxAreaElement.classList.remove('hovered'); this.classList.remove('hovered'); } }, /** * Set the authentication type for the pod. * @param {number} An auth type value defined in the AUTH_TYPE enum. * @param {string} authValue The initial value used for the auth type. */ setAuthType: function(authType, authValue) { this.authType_ = authType; this.authValue_ = authValue; this.setAttribute('auth-type', AUTH_TYPE_NAMES[this.authType_]); this.update(); this.reset(this.parentNode.isFocused(this)); }, /** * The auth type of the user pod. This value is one of the enum * values in AUTH_TYPE. * @type {number} */ get authType() { return this.authType_; }, /** * The initial value used for the pod's authentication type. * eg. a prepopulated password input when using password authentication. */ get authValue() { return this.authValue_; }, /** * True if the the user pod uses a password to authenticate. * @type {bool} */ get isAuthTypePassword() { return this.authType_ == AUTH_TYPE.OFFLINE_PASSWORD || this.authType_ == AUTH_TYPE.FORCE_OFFLINE_PASSWORD; }, /** * True if the the user pod uses a user click to authenticate. * @type {bool} */ get isAuthTypeUserClick() { return this.authType_ == AUTH_TYPE.USER_CLICK; }, /** * True if the the user pod uses a online sign in to authenticate. * @type {bool} */ get isAuthTypeOnlineSignIn() { return this.authType_ == AUTH_TYPE.ONLINE_SIGN_IN; }, /** * Updates the image element of the user. */ updateUserImage: function() { UserPod.userImageSalt_[this.user.username] = new Date().getTime(); this.update(); }, /** * Focuses on input element. */ focusInput: function() { // Move tabIndex from the whole pod to the main input. // Note: the |mainInput| can be the pod itself. this.tabIndex = -1; this.mainInput.tabIndex = UserPodTabOrder.POD_INPUT; this.mainInput.focus(); }, /** * Activates the pod. * @param {Event} e Event object. * @return {boolean} True if activated successfully. */ activate: function(e) { if (this.isAuthTypeOnlineSignIn) { this.showSigninUI(); } else if (this.isAuthTypeUserClick) { Oobe.disableSigninUI(); this.classList.toggle('signing-in', true); chrome.send('attemptUnlock', [this.user.username]); } else if (this.isAuthTypePassword) { if (this.fingerprintAuthenticated_) { this.fingerprintAuthenticated_ = false; return true; } var pinValue = this.pinKeyboard ? this.pinKeyboard.value : ''; var password = this.passwordElement.value || pinValue; if (!password) return false; Oobe.disableSigninUI(); chrome.send('authenticateUser', [ this.user.username, password, this.isPinShown() && !isNaN(password) ]); } else { console.error('Activating user pod with invalid authentication type: ' + this.authType); } return true; }, showSupervisedUserSigninWarning: function() { // Legacy supervised user token has been invalidated. // Make sure that pod is focused i.e. "Sign in" button is seen. this.parentNode.focusPod(this); var error = document.createElement('div'); var messageDiv = document.createElement('div'); messageDiv.className = 'error-message-bubble'; messageDiv.textContent = loadTimeData.getString('supervisedUserExpiredTokenWarning'); error.appendChild(messageDiv); $('bubble').showContentForElement( this.reauthWarningElement, cr.ui.Bubble.Attachment.TOP, error, this.reauthWarningElement.offsetWidth / 2, 4); // Move warning bubble up if it overlaps the shelf. var maxHeight = cr.ui.LoginUITools.getMaxHeightBeforeShelfOverlapping($('bubble')); if (maxHeight < $('bubble').offsetHeight) { $('bubble').showContentForElement( this.reauthWarningElement, cr.ui.Bubble.Attachment.BOTTOM, error, this.reauthWarningElement.offsetWidth / 2, 4); } }, /** * Shows signin UI for this user. */ showSigninUI: function() { if (this.user.legacySupervisedUser && !this.user.isDesktopUser) { this.showSupervisedUserSigninWarning(); } else { // Special case for multi-profiles sign in. We show users even if they // are not allowed per policy. Restrict those users from starting GAIA. if (this.multiProfilesPolicyApplied) return; this.parentNode.showSigninUI(this.user.emailAddress); } }, /** * Resets the input field and updates the tab order of pod controls. * @param {boolean} takeFocus If true, input field takes focus. */ reset: function(takeFocus) { this.passwordElement.value = ''; if (this.pinKeyboard) this.pinKeyboard.value = ''; this.updateInput_(); this.classList.toggle('signing-in', false); if (takeFocus) { if (!this.multiProfilesPolicyApplied) this.focusInput(); // This will set a custom tab order. } else this.resetTabOrder(); }, /** * Removes a user using the correct identifier based on user type. * @param {Object} user User to be removed. */ removeUser: function(user) { chrome.send('removeUser', [user.isDesktopUser ? user.profilePath : user.username]); }, /** * Handles a click event on action area button. * @param {Event} e Click event. */ handleActionAreaButtonClick_: function(e) { if (this.parentNode.disabled) return; this.isActionBoxMenuActive = !this.isActionBoxMenuActive; e.stopPropagation(); }, /** * Handles a keydown event on action area button. * @param {Event} e KeyDown event. */ handleActionAreaButtonKeyDown_: function(e) { if (this.disabled) return; switch (e.key) { case 'Enter': case ' ': if (this.parentNode.focusedPod_ && !this.isActionBoxMenuActive) this.isActionBoxMenuActive = true; e.stopPropagation(); break; case 'ArrowUp': case 'ArrowDown': if (this.isActionBoxMenuActive) { this.actionBoxMenuRemoveElement.tabIndex = UserPodTabOrder.POD_MENU_ITEM; this.actionBoxMenuRemoveElement.focus(); } e.stopPropagation(); break; // Ignore these two, so ChromeVox hotkeys don't close the menu before // they can navigate through it. case 'Shift': case 'Meta': break; case 'Escape': this.actionBoxAreaElement.focus(); this.isActionBoxMenuActive = false; e.stopPropagation(); break; case 'Tab': if (!this.parentNode.alwaysFocusSinglePod) this.parentNode.focusPod(); default: this.isActionBoxMenuActive = false; break; } }, /** * Handles a keydown event on menu title. * @param {Event} e KeyDown event. */ handleMenuTitleElementKeyDown_: function(e) { if (this.disabled) return; if (e.key != 'Tab') { this.handleActionAreaButtonKeyDown_(e); return; } if (e.shiftKey == false) { if (this.actionBoxMenuRemoveElement.hidden) { this.isActionBoxMenuActive = false; } else { this.actionBoxMenuRemoveElement.tabIndex = UserPodTabOrder.POD_MENU_ITEM; this.actionBoxMenuRemoveElement.focus(); e.preventDefault(); } } else { this.isActionBoxMenuActive = false; this.focusInput(); e.preventDefault(); } }, /** * Handles a blur event on menu title. * @param {Event} e Blur event. */ handleMenuTitleElementBlur_: function(e) { if (this.disabled) return; this.actionBoxMenuTitleElement.tabIndex = -1; }, /** * Handles a click event on remove user command. * @param {Event} e Click event. */ handleRemoveCommandClick_: function(e) { this.showRemoveWarning_(); }, /** * Move the action box menu up if needed. */ moveActionMenuUpIfNeeded_: function() { // Skip checking (computationally expensive) if already moved up. if (this.actionBoxMenu.classList.contains('menu-moved-up')) return; // Move up the menu if it overlaps shelf. var maxHeight = cr.ui.LoginUITools.getMaxHeightBeforeShelfOverlapping( this.actionBoxMenu, true); var actualHeight = parseInt( window.getComputedStyle(this.actionBoxMenu).height); if (maxHeight < actualHeight) { this.actionBoxMenu.classList.add('menu-moved-up'); this.actionBoxAreaElement.classList.add('menu-moved-up'); } }, /** * Shows remove user warning. Used for legacy supervised users * and non-device-owner on CrOS, and for all users on desktop. */ showRemoveWarning_: function() { this.actionBoxMenuRemoveElement.hidden = true; this.actionBoxRemoveUserWarningElement.hidden = false; if (!this.user.isDesktopUser) { this.moveActionMenuUpIfNeeded_(); if (!this.user.legacySupervisedUser) { this.querySelector( '.action-box-remove-user-warning-text').style.display = 'none'; this.querySelector( '.action-box-remove-user-warning-table-nonsync').style.display = 'none'; var message = loadTimeData.getString('removeNonOwnerUserWarningText'); this.updateRemoveNonOwnerUserWarningMessage_(this.user.profilePath, message); } } else { // Show extra statistics information for desktop users this.querySelector( '.action-box-remove-non-owner-user-warning-text').hidden = true; this.RemoveWarningDialogSetMessage_(); // set a global handler for the callback window.updateRemoveWarningDialog = this.updateRemoveWarningDialog_.bind(this); var is_synced_user = this.user.emailAddress !== ""; if (!is_synced_user) { chrome.send('removeUserWarningLoadStats', [this.user.profilePath]); } } chrome.send('logRemoveUserWarningShown'); }, /** * Refresh the statistics in the remove user warning dialog. * @param {string} profilePath The filepath of the URL (must be verified). * @param {Object} profileStats Statistics associated with profileURL. */ updateRemoveWarningDialog_: function(profilePath, profileStats) { if (profilePath !== this.user.profilePath) return; var stats_elements = this.statsMapElements; // Update individual statistics for (var key in profileStats) { if (stats_elements.hasOwnProperty(key)) { stats_elements[key].textContent = profileStats[key].count; } } }, /** * Set the new message in the dialog. */ RemoveWarningDialogSetMessage_: function() { var is_synced_user = this.user.emailAddress !== ""; message = loadTimeData.getString( is_synced_user ? 'removeUserWarningTextSync' : 'removeUserWarningTextNonSync'); this.updateRemoveWarningDialogSetMessage_(this.user.profilePath, message); }, /** * Refresh the message in the remove user warning dialog. * @param {string} profilePath The filepath of the URL (must be verified). * @param {string} message The message to be written. * @param {number|string=} count The number or string to replace $1 in * |message|. Can be omitted if $1 is not present in |message|. */ updateRemoveWarningDialogSetMessage_: function(profilePath, message, count) { if (profilePath !== this.user.profilePath) return; // Add localized messages where $1 will be replaced with // and $2 will be replaced with // . var element = this.querySelector('.action-box-remove-user-warning-text'); element.textContent = ''; messageParts = message.split(/(\$[12])/); var numParts = messageParts.length; for (var j = 0; j < numParts; j++) { if (messageParts[j] === '$1') { var elementToAdd = document.createElement('span'); elementToAdd.classList.add('total-count'); elementToAdd.textContent = count; element.appendChild(elementToAdd); } else if (messageParts[j] === '$2') { var elementToAdd = document.createElement('span'); elementToAdd.classList.add('email'); elementToAdd.textContent = this.user.emailAddress; element.appendChild(elementToAdd); } else { element.appendChild(document.createTextNode(messageParts[j])); } } this.moveActionMenuUpIfNeeded_(); }, /** * Update the message in the "remove non-owner user warning" dialog on CrOS. * @param {string} profilePath The filepath of the URL (must be verified). * @param (string) message The message to be written. */ updateRemoveNonOwnerUserWarningMessage_: function(profilePath, message) { if (profilePath !== this.user.profilePath) return; // Add localized messages where $1 will be replaced with // . var element = this.querySelector( '.action-box-remove-non-owner-user-warning-text'); element.textContent = ''; messageParts = message.split(/(\$[1])/); var numParts = messageParts.length; for (var j = 0; j < numParts; j++) { if (messageParts[j] == '$1') { var elementToAdd = document.createElement('span'); elementToAdd.classList.add('email'); elementToAdd.textContent = this.user.emailAddress; element.appendChild(elementToAdd); } else { element.appendChild(document.createTextNode(messageParts[j])); } } this.moveActionMenuUpIfNeeded_(); }, /** * Handles a click event on remove user confirmation button. * @param {Event} e Click event. */ handleRemoveUserConfirmationClick_: function(e) { if (this.isActionBoxMenuActive) { this.isActionBoxMenuActive = false; this.removeUser(this.user); e.stopPropagation(); } }, /** * Handles mouseover event on fingerprint icon. * @param {Event} e MouseOver event. */ handleFingerprintIconMouseOver_: function(e) { var bubbleContent = document.createElement('div'); bubbleContent.textContent = loadTimeData.getString('fingerprintIconMessage'); this.passwordElement.placeholder = loadTimeData.getString('fingerprintHint'); /** @const */ var BUBBLE_OFFSET = 25; /** @const */ var BUBBLE_PADDING = -8; var attachment = this.isPinShown() ? cr.ui.Bubble.Attachment.RIGHT : cr.ui.Bubble.Attachment.BOTTOM; var bubbleAnchor = this.getBubbleAnchorForFingerprintIcon_(); $('bubble').showContentForElement( bubbleAnchor, attachment, bubbleContent, BUBBLE_OFFSET, BUBBLE_PADDING, true); }, /** * Handles mouseout event on fingerprint icon. * @param {Event} e MouseOut event. */ handleFingerprintIconMouseOut_: function(e) { var bubbleAnchor = this.getBubbleAnchorForFingerprintIcon_(); $('bubble').hideForElement(bubbleAnchor); this.passwordElement.placeholder = loadTimeData.getString( this.isPinShown() ? 'pinKeyboardPlaceholderPinPassword' : 'passwordHint'); }, /** * Returns bubble anchor of the fingerprint icon. * @return {!HTMLElement} Anchor element of the bubble. */ getBubbleAnchorForFingerprintIcon_: function() { var bubbleAnchor = this; if (this.isPinShown()) bubbleAnchor = (this.getElementsByClassName('auth-container'))[0]; return bubbleAnchor; }, /** * Handles a keydown event on remove user confirmation button. * @param {Event} e KeyDown event. */ handleRemoveUserConfirmationKeyDown_: function(e) { if (!this.isActionBoxMenuActive) return; // Only handle pressing 'Enter' or 'Space', and let all other events // bubble to the action box menu. if (e.key == 'Enter' || e.key == ' ') { this.isActionBoxMenuActive = false; this.removeUser(this.user); e.stopPropagation(); // Prevent default so that we don't trigger a 'click' event. e.preventDefault(); } }, /** * Handles a keydown event on remove command. * @param {Event} e KeyDown event. */ handleRemoveCommandKeyDown_: function(e) { if (this.disabled) return; switch (e.key) { case 'Enter': e.preventDefault(); this.showRemoveWarning_(); e.stopPropagation(); break; case 'ArrowUp': case 'ArrowDown': e.stopPropagation(); break; // Ignore these two, so ChromeVox hotkeys don't close the menu before // they can navigate through it. case 'Shift': case 'Meta': break; case 'Escape': this.actionBoxAreaElement.focus(); this.isActionBoxMenuActive = false; e.stopPropagation(); break; default: this.actionBoxAreaElement.focus(); this.isActionBoxMenuActive = false; break; } }, /** * Handles a blur event on remove command. * @param {Event} e Blur event. */ handleRemoveCommandBlur_: function(e) { if (this.disabled) return; this.actionBoxMenuRemoveElement.tabIndex = -1; }, /** * Handles mouse down event. It sets whether the user click auth will be * allowed on the next mouse click event. The auth is allowed iff the pod * was focused on the mouse down event starting the click. * @param {Event} e The mouse down event. */ handlePodMouseDown_: function(e) { this.userClickAuthAllowed_ = this.parentNode.isFocused(this); }, /** * Called when the input of the password element changes. Updates the submit * button color and state and hides the error popup bubble. */ updateInput_: function() { if (this.submitButton) { this.submitButton.disabled = this.passwordElement.value.length == 0; if (this.isFingerprintIconShown()) { this.submitButton.hidden = this.passwordElement.value.length == 0; } else { this.submitButton.hidden = false; } } this.showError = false; $('bubble').hide(); }, /** * Handles input event on the password element. * @param {Event} e Input event. */ handleInputChanged_: function(e) { this.updateInput_(); }, /** * Handles click event on a user pod. * @param {Event} e Click event. */ handleClickOnPod_: function(e) { if (this.parentNode.disabled) return; if (!this.isActionBoxMenuActive) { if (this.isAuthTypeOnlineSignIn) { this.showSigninUI(); } else if (this.isAuthTypeUserClick && this.userClickAuthAllowed_) { // Note that this.userClickAuthAllowed_ is set in mouse down event // handler. this.parentNode.setActivatedPod(this); } else if (this.pinKeyboard && e.target == this.pinKeyboard.submitButton) { // Sets the pod as activated if the submit button is clicked so that // it simulates what the enter button does for the password/pin. this.parentNode.setActivatedPod(this); } if (this.multiProfilesPolicyApplied) this.userTypeBubbleElement.classList.add('bubble-shown'); // Prevent default so that we don't trigger 'focus' event and // stop propagation so that the 'click' event does not bubble // up and accidentally closes the bubble tooltip. stopEventPropagation(e); } }, /** * Handles keydown event for a user pod. * @param {Event} e Key event. */ handlePodKeyDown_: function(e) { if (!this.isAuthTypeUserClick || this.disabled) return; switch (e.key) { case 'Enter': case ' ': if (this.parentNode.isFocused(this)) this.parentNode.setActivatedPod(this); break; } } }; /** * Creates a public account user pod. * @constructor * @extends {UserPod} */ var PublicAccountUserPod = cr.ui.define(function() { var node = UserPod(); var extras = $('public-account-user-pod-extras-template').children; for (var i = 0; i < extras.length; ++i) { var el = extras[i].cloneNode(true); node.appendChild(el); } return node; }); PublicAccountUserPod.prototype = { __proto__: UserPod.prototype, /** * "Enter" button in expanded side pane. * @type {!HTMLButtonElement} */ get enterButtonElement() { return this.querySelector('.enter-button'); }, /** * Boolean flag of whether the pod is showing the side pane. The flag * controls whether 'expanded' class is added to the pod's class list and * resets tab order because main input element changes when the 'expanded' * state changes. * @type {boolean} */ get expanded() { return this.classList.contains('expanded'); }, set expanded(expanded) { if (this.expanded == expanded) return; this.resetTabOrder(); this.classList.toggle('expanded', expanded); if (expanded) { // Show the advanced expanded pod directly if there are at least two // recommended locales. This will be the case in multilingual // environments where users are likely to want to choose among locales. if (this.querySelector('.language-select').multipleRecommendedLocales) this.classList.add('advanced'); this.usualLeft = this.left; this.makeSpaceForExpandedPod_(); } else if (typeof(this.usualLeft) != 'undefined') { this.left = this.usualLeft; } var self = this; this.classList.add('animating'); this.addEventListener('transitionend', function f(e) { self.removeEventListener('transitionend', f); self.classList.remove('animating'); // Accessibility focus indicator does not move with the focused // element. Sends a 'focus' event on the currently focused element // so that accessibility focus indicator updates its location. if (document.activeElement) document.activeElement.dispatchEvent(new Event('focus')); }); // Guard timer set to animation duration + 20ms. ensureTransitionEndEvent(this, 200); }, get advanced() { return this.classList.contains('advanced'); }, /** @override */ get mainInput() { if (this.expanded) return this.enterButtonElement; else return this.nameElement; }, /** @override */ decorate: function() { UserPod.prototype.decorate.call(this); this.classList.add('public-account'); this.nameElement.addEventListener('keydown', (function(e) { if (e.key == 'Enter') { this.parentNode.setActivatedPod(this, e); // Stop this keydown event from bubbling up to PodRow handler. e.stopPropagation(); // Prevent default so that we don't trigger a 'click' event on the // newly focused "Enter" button. e.preventDefault(); } }).bind(this)); var learnMore = this.querySelector('.learn-more'); learnMore.addEventListener('mousedown', stopEventPropagation); learnMore.addEventListener('click', this.handleLearnMoreEvent); learnMore.addEventListener('keydown', this.handleLearnMoreEvent); learnMore = this.querySelector('.expanded-pane-learn-more'); learnMore.addEventListener('click', this.handleLearnMoreEvent); learnMore.addEventListener('keydown', this.handleLearnMoreEvent); var languageSelect = this.querySelector('.language-select'); languageSelect.tabIndex = UserPodTabOrder.POD_INPUT; languageSelect.manuallyChanged = false; languageSelect.addEventListener( 'change', function() { languageSelect.manuallyChanged = true; this.getPublicSessionKeyboardLayouts_(); }.bind(this)); var keyboardSelect = this.querySelector('.keyboard-select'); keyboardSelect.tabIndex = UserPodTabOrder.POD_INPUT; keyboardSelect.loadedLocale = null; var languageAndInput = this.querySelector('.language-and-input'); languageAndInput.tabIndex = UserPodTabOrder.POD_INPUT; languageAndInput.addEventListener('click', this.transitionToAdvanced_.bind(this)); var monitoringLearnMore = this.querySelector('.monitoring-learn-more'); monitoringLearnMore.tabIndex = UserPodTabOrder.POD_INPUT; monitoringLearnMore.addEventListener( 'click', this.onMonitoringLearnMoreClicked_.bind(this)); this.enterButtonElement.addEventListener('click', (function(e) { this.enterButtonElement.disabled = true; var locale = this.querySelector('.language-select').value; var keyboardSelect = this.querySelector('.keyboard-select'); // The contents of |keyboardSelect| is updated asynchronously. If its // locale does not match |locale|, it has not updated yet and the // currently selected keyboard layout may not be applicable to |locale|. // Do not return any keyboard layout in this case and let the backend // choose a suitable layout. var keyboardLayout = keyboardSelect.loadedLocale == locale ? keyboardSelect.value : ''; chrome.send('launchPublicSession', [this.user.username, locale, keyboardLayout]); }).bind(this)); }, /** @override **/ initialize: function() { UserPod.prototype.initialize.call(this); id = this.user.username + '-keyboard'; this.querySelector('.keyboard-select-label').htmlFor = id; this.querySelector('.keyboard-select').setAttribute('id', id); var id = this.user.username + '-language'; this.querySelector('.language-select-label').htmlFor = id; var languageSelect = this.querySelector('.language-select'); languageSelect.setAttribute('id', id); this.populateLanguageSelect(this.user.initialLocales, this.user.initialLocale, this.user.initialMultipleRecommendedLocales); }, /** @override **/ update: function() { UserPod.prototype.update.call(this); this.querySelector('.expanded-pane-name').textContent = this.user_.displayName; this.querySelector('.info').textContent = loadTimeData.getStringF('publicAccountInfoFormat', this.user_.enterpriseDisplayDomain); }, /** @override */ focusInput: function() { // Move tabIndex from the whole pod to the main input. this.tabIndex = -1; this.mainInput.tabIndex = UserPodTabOrder.POD_INPUT; this.mainInput.focus(); }, /** @override */ reset: function(takeFocus) { if (!takeFocus) this.expanded = false; this.enterButtonElement.disabled = false; UserPod.prototype.reset.call(this, takeFocus); }, /** @override */ activate: function(e) { if (!this.expanded) { this.expanded = true; this.focusInput(); } return true; }, /** @override */ handleClickOnPod_: function(e) { if (this.parentNode.disabled) return; this.parentNode.focusPod(this); this.parentNode.setActivatedPod(this, e); // Prevent default so that we don't trigger 'focus' event. e.preventDefault(); }, /** * Updates the display name shown on the pod. * @param {string} displayName The new display name */ setDisplayName: function(displayName) { this.user_.displayName = displayName; this.update(); }, /** * Handle mouse and keyboard events for the learn more button. Triggering * the button causes information about public sessions to be shown. * @param {Event} event Mouse or keyboard event. */ handleLearnMoreEvent: function(event) { switch (event.type) { // Show informaton on left click. Let any other clicks propagate. case 'click': if (event.button != 0) return; break; // Show informaton when or is pressed. Let any other // key presses propagate. case 'keydown': switch (event.keyCode) { case 13: // Return. case 32: // Space. break; default: return; } break; } chrome.send('launchHelpApp', [HELP_TOPIC_PUBLIC_SESSION]); stopEventPropagation(event); }, makeSpaceForExpandedPod_: function() { var width = this.classList.contains('advanced') ? PUBLIC_EXPANDED_ADVANCED_WIDTH : PUBLIC_EXPANDED_BASIC_WIDTH; var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; var rowPadding = isDesktopUserManager ? DESKTOP_ROW_PADDING : POD_ROW_PADDING; if (this.left + width > $('pod-row').offsetWidth - rowPadding) this.left = $('pod-row').offsetWidth - rowPadding - width; }, /** * Transition the expanded pod from the basic to the advanced view. */ transitionToAdvanced_: function() { var pod = this; var languageAndInputSection = this.querySelector('.language-and-input-section'); this.classList.add('transitioning-to-advanced'); setTimeout(function() { pod.classList.add('advanced'); pod.makeSpaceForExpandedPod_(); languageAndInputSection.addEventListener('transitionend', function observer() { languageAndInputSection.removeEventListener('transitionend', observer); pod.classList.remove('transitioning-to-advanced'); pod.querySelector('.language-select').focus(); }); // Guard timer set to animation duration + 20ms. ensureTransitionEndEvent(languageAndInputSection, 380); }, 0); }, /** * Show a dialog when user clicks on learn more (monitoring) button. */ onMonitoringLearnMoreClicked_: function() { if (!this.dialogContainer_) { this.dialogContainer_ = document.createElement('div'); this.dialogContainer_.classList.add('monitoring-dialog-container'); var topContainer = document.querySelector('#scroll-container'); topContainer.appendChild(this.dialogContainer_); } // Public Session POD in advanced view has a different size so add a dummy // parent element to enable different CSS settings. this.dialogContainer_.classList.toggle( 'advanced', this.classList.contains('advanced')) var html = ''; var infoItems = ['publicAccountMonitoringInfoItem1', 'publicAccountMonitoringInfoItem2', 'publicAccountMonitoringInfoItem3', 'publicAccountMonitoringInfoItem4']; for (item of infoItems) { html += '

'; html += loadTimeData.getString(item); html += '

'; } var title = loadTimeData.getString('publicAccountMonitoringInfo'); this.dialog_ = new cr.ui.dialogs.BaseDialog(this.dialogContainer_); this.dialog_.showHtml(title, html, undefined, this.onMonitoringDialogClosed_.bind(this)); this.parentNode.disabled = true; }, /** * Cleanup after the monitoring warning dialog is closed. */ onMonitoringDialogClosed_: function() { this.parentNode.disabled = false; this.dialog_ = undefined; }, /** * Retrieves the list of keyboard layouts available for the currently * selected locale. */ getPublicSessionKeyboardLayouts_: function() { var selectedLocale = this.querySelector('.language-select').value; if (selectedLocale == this.querySelector('.keyboard-select').loadedLocale) { // If the list of keyboard layouts was loaded for the currently selected // locale, it is already up to date. return; } chrome.send('getPublicSessionKeyboardLayouts', [this.user.username, selectedLocale]); }, /** * Populates the keyboard layout "select" element with a list of layouts. * @param {string} locale The locale to which this list of keyboard layouts * applies * @param {!Object} list List of available keyboard layouts */ populateKeyboardSelect: function(locale, list) { if (locale != this.querySelector('.language-select').value) { // The selected locale has changed and the list of keyboard layouts is // not applicable. This method will be called again when a list of // keyboard layouts applicable to the selected locale is retrieved. return; } var keyboardSelect = this.querySelector('.keyboard-select'); keyboardSelect.loadedLocale = locale; keyboardSelect.innerHTML = ''; for (var i = 0; i < list.length; ++i) { var item = list[i]; keyboardSelect.appendChild( new Option(item.title, item.value, item.selected, item.selected)); } }, /** * Populates the language "select" element with a list of locales. * @param {!Object} locales The list of available locales * @param {string} defaultLocale The locale to select by default * @param {boolean} multipleRecommendedLocales Whether |locales| contains * two or more recommended locales */ populateLanguageSelect: function(locales, defaultLocale, multipleRecommendedLocales) { var languageSelect = this.querySelector('.language-select'); // If the user manually selected a locale, do not change the selection. // Otherwise, select the new |defaultLocale|. var selected = languageSelect.manuallyChanged ? languageSelect.value : defaultLocale; languageSelect.innerHTML = ''; var group = languageSelect; for (var i = 0; i < locales.length; ++i) { var item = locales[i]; if (item.optionGroupName) { group = document.createElement('optgroup'); group.label = item.optionGroupName; languageSelect.appendChild(group); } else { group.appendChild(new Option(item.title, item.value, item.value == selected, item.value == selected)); } } languageSelect.multipleRecommendedLocales = multipleRecommendedLocales; // Retrieve a list of keyboard layouts applicable to the locale that is // now selected. this.getPublicSessionKeyboardLayouts_(); } }; /** * Creates a user pod to be used only in desktop chrome. * @constructor * @extends {UserPod} */ var DesktopUserPod = cr.ui.define(function() { // Don't just instantiate a UserPod(), as this will call decorate() on the // parent object, and add duplicate event listeners. var node = $('user-pod-template').cloneNode(true); node.removeAttribute('id'); return node; }); DesktopUserPod.prototype = { __proto__: UserPod.prototype, /** @override */ initialize: function() { if (this.user.needsSignin) { if (this.user.hasLocalCreds) { this.user.initialAuthType = AUTH_TYPE.OFFLINE_PASSWORD; } else { this.user.initialAuthType = AUTH_TYPE.ONLINE_SIGN_IN; } } UserPod.prototype.initialize.call(this); }, /** @override */ get mainInput() { if (this.user.needsSignin && this.user.hasLocalCreds) return this.passwordElement; else return this.nameElement; }, /** @override */ update: function() { this.imageElement.src = this.user.userImage; this.animatedImageElement.src = this.user.userImage; this.nameElement.textContent = this.user.displayName; this.reauthNameHintElement.textContent = this.user.displayName; var isLockedUser = this.user.needsSignin; var isLegacySupervisedUser = this.user.legacySupervisedUser; var isChildUser = this.user.childUser; var isSyncedUser = this.user.emailAddress !== ""; var isProfileLoaded = this.user.isProfileLoaded; this.classList.toggle('locked', isLockedUser); this.classList.toggle('legacy-supervised', isLegacySupervisedUser); this.classList.toggle('child', isChildUser); this.classList.toggle('synced', isSyncedUser); if (this.isAuthTypeUserClick) this.passwordLabelElement.textContent = this.authValue; this.passwordElement.setAttribute('aria-label', loadTimeData.getStringF( 'passwordFieldAccessibleName', this.user_.emailAddress)); UserPod.prototype.updateActionBoxArea.call(this); }, /** @override */ activate: function(e) { if (!this.user.needsSignin) { Oobe.launchUser(this.user.profilePath); } else if (this.user.hasLocalCreds && !this.passwordElement.value) { return false; } else { chrome.send('authenticatedLaunchUser', [this.user.profilePath, this.user.emailAddress, this.passwordElement.value]); } this.passwordElement.value = ''; return true; }, /** @override */ handleClickOnPod_: function(e) { if (this.parentNode.disabled) return; Oobe.clearErrors(); this.parentNode.lastFocusedPod_ = this; // If this is a locked pod and there are local credentials, show the // password field. Otherwise call activate() which will open up a browser // window or show the reauth dialog, as needed. if (!(this.user.needsSignin && this.user.hasLocalCreds) && !this.isActionBoxMenuActive) { this.activate(e); } if (this.isAuthTypeUserClick) chrome.send('attemptUnlock', [this.user.emailAddress]); }, }; /** * Creates a user pod that represents kiosk app. * @constructor * @extends {UserPod} */ var KioskAppPod = cr.ui.define(function() { var node = UserPod(); return node; }); KioskAppPod.prototype = { __proto__: UserPod.prototype, /** @override */ decorate: function() { UserPod.prototype.decorate.call(this); this.launchAppButtonElement.addEventListener('click', this.activate.bind(this)); }, /** @override */ update: function() { this.imageElement.src = this.user.iconUrl; this.imageElement.alt = this.user.label; this.imageElement.title = this.user.label; this.animatedImageElement.src = this.user.iconUrl; this.animatedImageElement.alt = this.user.label; this.animatedImageElement.title = this.user.label; this.passwordEntryContainerElement.hidden = true; this.launchAppButtonContainerElement.hidden = false; this.nameElement.textContent = this.user.label; this.reauthNameHintElement.textContent = this.user.label; UserPod.prototype.updateActionBoxArea.call(this); UserPod.prototype.customizeUserPodPerUserType.call(this); }, /** @override */ get mainInput() { return this.launchAppButtonElement; }, /** @override */ focusInput: function() { // Move tabIndex from the whole pod to the main input. this.tabIndex = -1; this.mainInput.tabIndex = UserPodTabOrder.POD_INPUT; this.mainInput.focus(); }, /** @override */ get forceOnlineSignin() { return false; }, /** @override */ activate: function(e) { var diagnosticMode = e && e.ctrlKey; this.launchApp_(this.user, diagnosticMode); return true; }, /** @override */ handleClickOnPod_: function(e) { if (this.parentNode.disabled) return; Oobe.clearErrors(); this.parentNode.lastFocusedPod_ = this; this.activate(e); }, /** * Launch the app. If |diagnosticMode| is true, ask user to confirm. * @param {Object} app App data. * @param {boolean} diagnosticMode Whether to run the app in diagnostic * mode. */ launchApp_: function(app, diagnosticMode) { if (!diagnosticMode) { chrome.send('launchKioskApp', [app.id, false]); return; } var oobe = $('oobe'); if (!oobe.confirmDiagnosticMode_) { oobe.confirmDiagnosticMode_ = new cr.ui.dialogs.ConfirmDialog(document.body); oobe.confirmDiagnosticMode_.setOkLabel( loadTimeData.getString('confirmKioskAppDiagnosticModeYes')); oobe.confirmDiagnosticMode_.setCancelLabel( loadTimeData.getString('confirmKioskAppDiagnosticModeNo')); } oobe.confirmDiagnosticMode_.show( loadTimeData.getStringF('confirmKioskAppDiagnosticModeFormat', app.label), function() { chrome.send('launchKioskApp', [app.id, true]); }); }, }; /** * Creates a new pod row element. * @constructor * @extends {HTMLDivElement} */ var PodRow = cr.ui.define('podrow'); PodRow.prototype = { __proto__: HTMLDivElement.prototype, // Whether this user pod row is shown for the first time. firstShown_: true, // True if inside focusPod(). insideFocusPod_: false, // Focused pod. focusedPod_: undefined, // Activated pod, i.e. the pod of current login attempt. activatedPod_: undefined, // Pod that was most recently focused, if any. lastFocusedPod_: undefined, // Pods whose initial images haven't been loaded yet. podsWithPendingImages_: [], // Whether pod placement has been postponed. podPlacementPostponed_: false, // Standard user pod height/width. userPodHeight_: 0, userPodWidth_: 0, // Array of apps that are shown in addition to other user pods. apps_: [], // True to show app pods along with user pods. shouldShowApps_: true, // Array of users that are shown (public/supervised/regular). users_: [], // If we're in tablet mode. tabletModeEnabled_: false, /** @override */ decorate: function() { // Event listeners that are installed for the time period during which // the element is visible. this.listeners_ = { focus: [this.handleFocus_.bind(this), true /* useCapture */], click: [this.handleClick_.bind(this), true], mousemove: [this.handleMouseMove_.bind(this), false], keydown: [this.handleKeyDown.bind(this), false] }; var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; var isNewDesktopUserManager = Oobe.getInstance().newDesktopUserManager; this.userPodHeight_ = isDesktopUserManager ? isNewDesktopUserManager ? MD_DESKTOP_POD_HEIGHT : DESKTOP_POD_HEIGHT : CROS_POD_HEIGHT; this.userPodWidth_ = isDesktopUserManager ? isNewDesktopUserManager ? MD_DESKTOP_POD_WIDTH : DESKTOP_POD_WIDTH : CROS_POD_WIDTH; }, /** * Returns all the pods in this pod row. * @type {NodeList} */ get pods() { return Array.prototype.slice.call(this.children); }, /** * Return true if user pod row has only single user pod in it, which should * always be focused except desktop and tablet modes. * @type {boolean} */ get alwaysFocusSinglePod() { var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; return (isDesktopUserManager || this.tabletModeEnabled_) ? false : this.children.length == 1; }, /** * Returns pod with the given app id. * @param {!string} app_id Application id to be matched. * @return {Object} Pod with the given app id. null if pod hasn't been * found. */ getPodWithAppId_: function(app_id) { for (var i = 0, pod; pod = this.pods[i]; ++i) { if (pod.user.isApp && pod.user.id == app_id) return pod; } return null; }, /** * Returns pod with the given username (null if there is no such pod). * @param {string} username Username to be matched. * @return {Object} Pod with the given username. null if pod hasn't been * found. */ getPodWithUsername_: function(username) { for (var i = 0, pod; pod = this.pods[i]; ++i) { if (pod.user.username == username) return pod; } return null; }, /** * True if the the pod row is disabled (handles no user interaction). * @type {boolean} */ disabled_: false, get disabled() { return this.disabled_; }, set disabled(value) { this.disabled_ = value; this.pods.forEach(function(pod) { pod.disabled = value; }); }, /** * Creates a user pod from given email. * @param {!Object} user User info dictionary. */ createUserPod: function(user) { var userPod; if (user.isDesktopUser) userPod = new DesktopUserPod({user: user}); else if (user.publicAccount) userPod = new PublicAccountUserPod({user: user}); else if (user.isApp) userPod = new KioskAppPod({user: user}); else userPod = new UserPod({user: user}); userPod.hidden = false; return userPod; }, /** * Add an existing user pod to this pod row. * @param {!Object} user User info dictionary. */ addUserPod: function(user) { var userPod = this.createUserPod(user); this.appendChild(userPod); userPod.initialize(); }, /** * Performs visual changes on the user pod if there is an error. * @param {boolean} visible Whether to show or hide the display. */ setFocusedPodErrorDisplay: function(visible) { if (this.focusedPod_) this.focusedPod_.showError = visible; }, /** * Shows or hides the pin keyboard for the current focused pod. * @param {boolean} visible */ setFocusedPodPinVisibility: function(visible) { if (this.focusedPod_) this.focusedPod_.setPinVisibility(visible); }, /** * Runs app with a given id from the list of loaded apps. * @param {!string} app_id of an app to run. * @param {boolean=} opt_diagnosticMode Whether to run the app in * diagnostic mode. Default is false. */ findAndRunAppForTesting: function(app_id, opt_diagnosticMode) { var app = this.getPodWithAppId_(app_id); if (app) { var activationEvent = cr.doc.createEvent('MouseEvents'); var ctrlKey = opt_diagnosticMode; activationEvent.initMouseEvent('click', true, true, null, 0, 0, 0, 0, 0, ctrlKey, false, false, false, 0, null); app.dispatchEvent(activationEvent); } }, /** * Enables or disables the pin keyboard for the given user. A disabled pin * keyboard will never be displayed. * * If the user's pod is focused, then enabling the pin keyboard will display * it; disabling the pin keyboard will hide it. * @param {!string} username * @param {boolean} enabled */ setPinEnabled: function(username, enabled) { var pod = this.getPodWithUsername_(username); if (!pod) { console.error('Attempt to enable/disable pin keyboard of missing pod.'); return; } // Make sure to set |pinEnabled| before toggling visiblity to avoid // validation errors. pod.pinEnabled = enabled; if (this.focusedPod_ == pod) { if (enabled) { ensurePinKeyboardLoaded( this.setPinVisibility.bind(this, username, true)); } else { this.setPinVisibility(username, false); } } }, /** * Shows or hides the pin keyboard from the pod with the given |username|. * This is only a visibility change; the pin keyboard can be reshown. * * Use setPinEnabled if the pin keyboard should be disabled for the given * user. * @param {!user} username * @param {boolean} visible */ setPinVisibility: function(username, visible) { var pod = this.getPodWithUsername_(username); if (!pod) { console.error('Attempt to show/hide pin keyboard of missing pod.'); return; } if (visible && pod.pinEnabled === false) { console.error('Attempt to show disabled pin keyboard'); return; } if (visible && this.focusedPod_ != pod) { console.error('Attempt to show pin keyboard on non-focused pod'); return; } pod.setPinVisibility(visible); }, /** * Removes user pod from pod row. * @param {!user} username */ removeUserPod: function(username) { var podToRemove = this.getPodWithUsername_(username); if (podToRemove == null) { console.warn('Attempt to remove pod that does not exist'); return; } this.removeChild(podToRemove); if (this.pods.length > 0) this.placePods_(); }, /** * Returns index of given pod or -1 if not found. * @param {UserPod} pod Pod to look up. * @private */ indexOf_: function(pod) { for (var i = 0; i < this.pods.length; ++i) { if (pod == this.pods[i]) return i; } return -1; }, /** * Populates pod row with given existing users and start init animation. * @param {array} users Array of existing user emails. */ loadPods: function(users) { this.users_ = users; this.rebuildPods(); }, /** * Scrolls focused user pod into view. */ scrollFocusedPodIntoView: function() { var pod = this.focusedPod_; if (!pod) return; // First check whether focused pod is already fully visible. var visibleArea = $('scroll-container'); // Visible area may not defined at user manager screen on all platforms. // Windows, Mac and Linux do not have visible area. if (!visibleArea) return; var scrollTop = visibleArea.scrollTop; var clientHeight = visibleArea.clientHeight; var podTop = $('oobe').offsetTop + pod.offsetTop; var padding = USER_POD_KEYBOARD_MIN_PADDING; if (podTop + pod.height + padding <= scrollTop + clientHeight && podTop - padding >= scrollTop) { return; } // Scroll so that user pod is as centered as possible. visibleArea.scrollTop = podTop - (clientHeight - pod.offsetHeight) / 2; }, /** * Rebuilds pod row using users_ and apps_ that were previously set or * updated. */ rebuildPods: function() { var emptyPodRow = this.pods.length == 0; // Clear existing pods. this.innerHTML = ''; this.focusedPod_ = undefined; this.activatedPod_ = undefined; this.lastFocusedPod_ = undefined; // Switch off animation Oobe.getInstance().toggleClass('flying-pods', false); // Populate the pod row. for (var i = 0; i < this.users_.length; ++i) this.addUserPod(this.users_[i]); for (var i = 0, pod; pod = this.pods[i]; ++i) this.podsWithPendingImages_.push(pod); // TODO(nkostylev): Edge case handling when kiosk apps are not fitting. if (this.shouldShowApps_) { for (var i = 0; i < this.apps_.length; ++i) this.addUserPod(this.apps_[i]); } // Make sure we eventually show the pod row, even if some image is stuck. setTimeout(function() { $('pod-row').classList.remove('images-loading'); }, POD_ROW_IMAGES_LOAD_TIMEOUT_MS); var isAccountPicker = $('login-header-bar').signinUIState == SIGNIN_UI_STATE.ACCOUNT_PICKER; // Immediately recalculate pods layout only when current UI is account // picker. Otherwise postpone it. if (isAccountPicker) { this.placePods_(); this.maybePreselectPod(); // Without timeout changes in pods positions will be animated even // though it happened when 'flying-pods' class was disabled. setTimeout(function() { Oobe.getInstance().toggleClass('flying-pods', true); }, 0); } else { this.podPlacementPostponed_ = true; // Update [Cancel] button state. if ($('login-header-bar').signinUIState == SIGNIN_UI_STATE.GAIA_SIGNIN && emptyPodRow && this.pods.length > 0) { login.GaiaSigninScreen.updateControlsState(); } } }, /** * Adds given apps to the pod row. * @param {array} apps Array of apps. */ setApps: function(apps) { this.apps_ = apps; this.rebuildPods(); chrome.send('kioskAppsLoaded'); // Check whether there's a pending kiosk app error. window.setTimeout(function() { chrome.send('checkKioskAppLaunchError'); }, 500); }, /** * Sets whether should show app pods. * @param {boolean} shouldShowApps Whether app pods should be shown. */ setShouldShowApps: function(shouldShowApps) { if (this.shouldShowApps_ == shouldShowApps) return; this.shouldShowApps_ = shouldShowApps; this.rebuildPods(); }, /** * Shows a custom icon on a user pod besides the input field. * @param {string} username Username of pod to add button * @param {!{id: !string, * hardlockOnClick: boolean, * isTrialRun: boolean, * ariaLabel: string | undefined, * tooltip: ({text: string, autoshow: boolean} | undefined)}} icon * The icon parameters. */ showUserPodCustomIcon: function(username, icon) { var pod = this.getPodWithUsername_(username); if (pod == null) { console.error('Unable to show user pod button: user pod not found.'); return; } if (!icon.id && !icon.tooltip) return; if (icon.id) pod.customIconElement.setIcon(icon.id); if (icon.isTrialRun) { pod.customIconElement.setInteractive( this.onDidClickLockIconDuringTrialRun_.bind(this, username)); } else if (icon.hardlockOnClick) { pod.customIconElement.setInteractive( this.hardlockUserPod_.bind(this, username)); } else { pod.customIconElement.setInteractive(null); } var ariaLabel = icon.ariaLabel || (icon.tooltip && icon.tooltip.text); if (ariaLabel) pod.customIconElement.setAriaLabel(ariaLabel); else console.warn('No ARIA label for user pod custom icon.'); pod.customIconElement.show(); // This has to be called after |show| in case the tooltip should be shown // immediatelly. pod.customIconElement.setTooltip( icon.tooltip || {text: '', autoshow: false}); // Hide fingerprint icon when custom icon is shown. this.setUserPodFingerprintIcon(username, FINGERPRINT_STATES.HIDDEN); }, /** * Hard-locks user pod for the user. If user pod is hard-locked, it can be * only unlocked using password, and the authentication type cannot be * changed. * @param {!string} username The user's username. * @private */ hardlockUserPod_: function(username) { chrome.send('hardlockPod', [username]); }, /** * Records a metric indicating that the user clicked on the lock icon during * the trial run for Easy Unlock. * @param {!string} username The user's username. * @private */ onDidClickLockIconDuringTrialRun_: function(username) { chrome.send('recordClickOnLockIcon', [username]); }, /** * Hides the custom icon in the user pod added by showUserPodCustomIcon(). * @param {string} username Username of pod to remove button */ hideUserPodCustomIcon: function(username) { var pod = this.getPodWithUsername_(username); if (pod == null) { console.error('Unable to hide user pod button: user pod not found.'); return; } // TODO(tengs): Allow option for a fading transition. pod.customIconElement.hide(); // Show fingerprint icon if applicable. this.setUserPodFingerprintIcon(username, FINGERPRINT_STATES.DEFAULT); }, /** * Set a fingerprint icon in the user pod of |username|. * @param {string} username Username of the selected user * @param {number} state Fingerprint unlock state */ setUserPodFingerprintIcon: function(username, state) { var pod = this.getPodWithUsername_(username); if (pod == null) { console.error( 'Unable to set user pod fingerprint icon: user pod not found.'); return; } pod.fingerprintAuthenticated_ = false; if (!pod.fingerprintIconElement) return; if (!pod.user.allowFingerprint || state == FINGERPRINT_STATES.HIDDEN || !pod.customIconElement.hidden) { pod.fingerprintIconElement.hidden = true; pod.submitButton.hidden = false; return; } FINGERPRINT_STATES_MAPPING.forEach(function(icon) { pod.fingerprintIconElement.classList.toggle( icon.class, state == icon.state); }); pod.fingerprintIconElement.hidden = false; pod.submitButton.hidden = pod.passwordElement.value.length == 0; this.updatePasswordField_(pod, state); if (state == FINGERPRINT_STATES.DEFAULT) return; pod.fingerprintAuthenticated_ = true; this.setActivatedPod(pod); if (state == FINGERPRINT_STATES.FAILED) { /** @const */ var RESET_ICON_TIMEOUT_MS = 500; setTimeout( this.resetIconAndPasswordField_.bind(this, pod), RESET_ICON_TIMEOUT_MS); } }, /** * Reset the fingerprint icon and password field. * @param {UserPod} pod Pod to reset. */ resetIconAndPasswordField_: function(pod) { if (!pod.fingerprintIconElement) return; this.setUserPodFingerprintIcon( pod.user.username, FINGERPRINT_STATES.DEFAULT); }, /** * Remove the fingerprint icon in the user pod. * @param {string} username Username of the selected user */ removeUserPodFingerprintIcon: function(username) { var pod = this.getPodWithUsername_(username); if (pod == null) { console.error('No user pod found (when removing fingerprint icon).'); return; } this.resetIconAndPasswordField_(pod); if (pod.fingerprintIconElement) { pod.fingerprintIconElement.parentNode.removeChild( pod.fingerprintIconElement); } pod.submitButton.hidden = false; }, /** * Updates the password field in the user pod. * @param {UserPod} pod Pod to update. * @param {number} state Fingerprint unlock state */ updatePasswordField_: function(pod, state) { FINGERPRINT_STATES_MAPPING.forEach(function(item) { pod.passwordElement.classList.toggle(item.class, state == item.state); }); var placeholderStr = loadTimeData.getString( pod.isPinShown() ? 'pinKeyboardPlaceholderPinPassword' : 'passwordHint'); if (state == FINGERPRINT_STATES.SIGNIN) { placeholderStr = loadTimeData.getString('fingerprintSigningin'); } else if (state == FINGERPRINT_STATES.FAILED) { placeholderStr = loadTimeData.getString('fingerprintSigninFailed'); } pod.passwordElement.placeholder = placeholderStr; }, /** * Sets the authentication type used to authenticate the user. * @param {string} username Username of selected user * @param {number} authType Authentication type, must be one of the * values listed in AUTH_TYPE enum. * @param {string} value The initial value to use for authentication. */ setAuthType: function(username, authType, value) { var pod = this.getPodWithUsername_(username); if (pod == null) { console.error('Unable to set auth type: user pod not found.'); return; } pod.setAuthType(authType, value); }, /** * Sets the state of tablet mode. * @param {boolean} isTabletModeEnabled true if the mode is on. */ setTabletModeState: function(isTabletModeEnabled) { this.tabletModeEnabled_ = isTabletModeEnabled; this.pods.forEach(function(pod, index) { pod.actionBoxAreaElement.classList.toggle( 'forced', isTabletModeEnabled); }); }, /** * Updates the display name shown on a public session pod. * @param {string} userID The user ID of the public session * @param {string} displayName The new display name */ setPublicSessionDisplayName: function(userID, displayName) { var pod = this.getPodWithUsername_(userID); if (pod != null) pod.setDisplayName(displayName); }, /** * Updates the list of locales available for a public session. * @param {string} userID The user ID of the public session * @param {!Object} locales The list of available locales * @param {string} defaultLocale The locale to select by default * @param {boolean} multipleRecommendedLocales Whether |locales| contains * two or more recommended locales */ setPublicSessionLocales: function(userID, locales, defaultLocale, multipleRecommendedLocales) { var pod = this.getPodWithUsername_(userID); if (pod != null) { pod.populateLanguageSelect(locales, defaultLocale, multipleRecommendedLocales); } }, /** * Updates the list of available keyboard layouts for a public session pod. * @param {string} userID The user ID of the public session * @param {string} locale The locale to which this list of keyboard layouts * applies * @param {!Object} list List of available keyboard layouts */ setPublicSessionKeyboardLayouts: function(userID, locale, list) { var pod = this.getPodWithUsername_(userID); if (pod != null) pod.populateKeyboardSelect(locale, list); }, /** * Called when window was resized. */ onWindowResize: function() { var layout = this.calculateLayout_(); if (layout.columns != this.columns || layout.rows != this.rows) this.placePods_(); // Wrap this in a set timeout so the function is called after the pod is // finished transitioning so that we work with the final pod dimensions. // If there is no focused pod that may be transitioning when this function // is called, we can call scrollFocusedPodIntoView() right away. var timeOut = 0; if (this.focusedPod_) { var style = getComputedStyle(this.focusedPod_); timeOut = parseFloat(style.transitionDuration) * 1000; } setTimeout(function() { this.scrollFocusedPodIntoView(); }.bind(this), timeOut); }, /** * Returns width of podrow having |columns| number of columns. * @private */ columnsToWidth_: function(columns) { var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; var margin = isDesktopUserManager ? DESKTOP_MARGIN_BY_COLUMNS[columns] : MARGIN_BY_COLUMNS[columns]; var rowPadding = isDesktopUserManager ? DESKTOP_ROW_PADDING : POD_ROW_PADDING; return 2 * rowPadding + columns * this.userPodWidth_ + (columns - 1) * margin; }, /** * Returns height of podrow having |rows| number of rows. * @private */ rowsToHeight_: function(rows) { var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; var rowPadding = isDesktopUserManager ? DESKTOP_ROW_PADDING : POD_ROW_PADDING; return 2 * rowPadding + rows * this.userPodHeight_; }, /** * Calculates number of columns and rows that podrow should have in order to * hold as much its pods as possible for current screen size. Also it tries * to choose layout that looks good. * @return {{columns: number, rows: number}} */ calculateLayout_: function() { var preferredColumns = this.pods.length < COLUMNS.length ? COLUMNS[this.pods.length] : COLUMNS[COLUMNS.length - 1]; var maxWidth = Oobe.getInstance().clientAreaSize.width; var columns = preferredColumns; while (maxWidth < this.columnsToWidth_(columns) && columns > 1) --columns; var rows = Math.floor((this.pods.length - 1) / columns) + 1; if (getComputedStyle( $('signin-banner'), null).getPropertyValue('display') != 'none') { rows = Math.min(rows, MAX_NUMBER_OF_ROWS_UNDER_SIGNIN_BANNER); } if (!Oobe.getInstance().newDesktopUserManager) { var maxHeigth = Oobe.getInstance().clientAreaSize.height; while (maxHeigth < this.rowsToHeight_(rows) && rows > 1) --rows; } // One more iteration if it's not enough cells to place all pods. while (maxWidth >= this.columnsToWidth_(columns + 1) && columns * rows < this.pods.length && columns < MAX_NUMBER_OF_COLUMNS) { ++columns; } return {columns: columns, rows: rows}; }, /** * Places pods onto their positions onto pod grid. * @private */ placePods_: function() { var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; if (isDesktopUserManager && !Oobe.getInstance().userPodsPageVisible) return; var layout = this.calculateLayout_(); var columns = this.columns = layout.columns; var rows = this.rows = layout.rows; var maxPodsNumber = columns * rows; var margin = isDesktopUserManager ? DESKTOP_MARGIN_BY_COLUMNS[columns] : MARGIN_BY_COLUMNS[columns]; this.parentNode.setPreferredSize( this.columnsToWidth_(columns), this.rowsToHeight_(rows)); var height = this.userPodHeight_; var width = this.userPodWidth_; var pinPodLocation = { column: columns + 1, row: rows + 1 }; if (this.focusedPod_ && this.focusedPod_.isPinShown()) pinPodLocation = this.findPodLocation_(this.focusedPod_, columns, rows); this.pods.forEach(function(pod, index) { if (index >= maxPodsNumber) { pod.hidden = true; return; } pod.hidden = false; if (pod.offsetHeight != height && pod.offsetHeight != CROS_PIN_POD_HEIGHT) { console.error('Pod offsetHeight (' + pod.offsetHeight + ') and POD_HEIGHT (' + height + ') are not equal.'); } if (pod.offsetWidth != width) { console.error('Pod offsetWidth (' + pod.offsetWidth + ') and POD_WIDTH (' + width + ') are not equal.'); } var column = index % columns; var row = Math.floor(index / columns); var rowPadding = isDesktopUserManager ? DESKTOP_ROW_PADDING : POD_ROW_PADDING; pod.left = rowPadding + column * (width + margin); // On desktop, we want the rows to always be equally spaced. pod.top = isDesktopUserManager ? row * (height + rowPadding) : row * height + rowPadding; }); Oobe.getInstance().updateScreenSize(this.parentNode); }, /** * Number of columns. * @type {?number} */ set columns(columns) { // Cannot use 'columns' here. this.setAttribute('ncolumns', columns); }, get columns() { return parseInt(this.getAttribute('ncolumns')); }, /** * Number of rows. * @type {?number} */ set rows(rows) { // Cannot use 'rows' here. this.setAttribute('nrows', rows); }, get rows() { return parseInt(this.getAttribute('nrows')); }, /** * Whether the pod is currently focused. * @param {UserPod} pod Pod to check for focus. * @return {boolean} Pod focus status. */ isFocused: function(pod) { return this.focusedPod_ == pod; }, /** * Focuses a given user pod or clear focus when given null. * @param {UserPod=} podToFocus User pod to focus (undefined clears focus). * @param {boolean=} opt_force If true, forces focus update even when * podToFocus is already focused. * @param {boolean=} opt_skipInputFocus If true, don't focus on the input * box of user pod. */ focusPod: function(podToFocus, opt_force, opt_skipInputFocus) { if (this.isFocused(podToFocus) && !opt_force) { // Calling focusPod w/o podToFocus means reset. if (!podToFocus) Oobe.clearErrors(); return; } // Make sure there's only one focusPod operation happening at a time. if (this.insideFocusPod_) { return; } this.insideFocusPod_ = true; for (var i = 0, pod; pod = this.pods[i]; ++i) { if (!this.alwaysFocusSinglePod) { pod.isActionBoxMenuActive = false; } if (pod != podToFocus) { pod.isActionBoxMenuHovered = false; pod.classList.remove('focused'); pod.setPinVisibility(false); this.setUserPodFingerprintIcon( pod.user.username, FINGERPRINT_STATES.HIDDEN); // On Desktop, the faded style is not set correctly, so we should // manually fade out non-focused pods if there is a focused pod. if (pod.user.isDesktopUser && podToFocus) pod.classList.add('faded'); else pod.classList.remove('faded'); pod.reset(false); } } // Clear any error messages for previous pod. if (!this.isFocused(podToFocus)) Oobe.clearErrors(); this.focusedPod_ = podToFocus; if (podToFocus) { // Only show the keyboard if it is fully loaded. if (podToFocus.isPinReady()) podToFocus.setPinVisibility(true); podToFocus.classList.remove('faded'); podToFocus.classList.add('focused'); if (!podToFocus.multiProfilesPolicyApplied) { podToFocus.classList.toggle('signing-in', false); if (!opt_skipInputFocus) podToFocus.focusInput(); } else { podToFocus.userTypeBubbleElement.classList.add('bubble-shown'); // Note it is not necessary to skip this focus request when // |opt_skipInputFocus| is true. When |multiProfilesPolicyApplied| // is false, it doesn't focus on the password input box by default. podToFocus.focus(); } if (!podToFocus.user.isApp) chrome.send( 'focusPod', [podToFocus.user.username, true /* loads wallpaper */]); this.firstShown_ = false; this.lastFocusedPod_ = podToFocus; this.scrollFocusedPodIntoView(); this.setUserPodFingerprintIcon( podToFocus.user.username, FINGERPRINT_STATES.DEFAULT); } else { chrome.send('noPodFocused'); } this.insideFocusPod_ = false; }, /** * Returns the currently activated pod. * @type {UserPod} */ get activatedPod() { return this.activatedPod_; }, /** * Sets currently activated pod. * @param {UserPod} pod Pod to check for focus. * @param {Event} e Event object. */ setActivatedPod: function(pod, e) { if (this.disabled) { console.error('Cannot activate pod while sign-in UI is disabled.'); return; } if (pod && pod.activate(e)) this.activatedPod_ = pod; }, /** * The pod of the signed-in user, if any; null otherwise. * @type {?UserPod} */ get lockedPod() { for (var i = 0, pod; pod = this.pods[i]; ++i) { if (pod.user.signedIn) return pod; } return null; }, /** * The pod that is preselected on user pod row show. * @type {?UserPod} */ get preselectedPod() { var isDesktopUserManager = Oobe.getInstance().displayType == DISPLAY_TYPE.DESKTOP_USER_MANAGER; if (isDesktopUserManager) { // On desktop, don't pre-select a pod if it's the only one. if (this.pods.length == 1) return null; // The desktop User Manager can send an URI encoded profile path in the // url hash, that indicates a pod that should be initially focused. var focusedProfilePath = decodeURIComponent(window.location.hash.substr(1)); for (var i = 0, pod; pod = this.pods[i]; ++i) { if (focusedProfilePath === pod.user.profilePath) return pod; } return null; } var lockedPod = this.lockedPod; if (lockedPod) return lockedPod; for (i = 0; pod = this.pods[i]; ++i) { if (!pod.multiProfilesPolicyApplied) return pod; } return this.pods[0]; }, /** * Resets input UI. * @param {boolean} takeFocus True to take focus. */ reset: function(takeFocus) { this.disabled = false; if (this.activatedPod_) this.activatedPod_.reset(takeFocus); }, /** * Restores input focus to current selected pod, if there is any. */ refocusCurrentPod: function() { if (this.focusedPod_ && !this.focusedPod_.multiProfilesPolicyApplied) { this.focusedPod_.focusInput(); } }, /** * Clears focused pod password field. */ clearFocusedPod: function() { if (!this.disabled && this.focusedPod_) this.focusedPod_.reset(true); }, /** * Shows signin UI. * @param {string} email Email for signin UI. */ showSigninUI: function(email) { // Clear any error messages that might still be around. Oobe.clearErrors(); this.disabled = true; this.lastFocusedPod_ = this.getPodWithUsername_(email); Oobe.showSigninUI(email); }, /** * Updates current image of a user. * @param {string} username User for which to update the image. */ updateUserImage: function(username) { var pod = this.getPodWithUsername_(username); if (pod) pod.updateUserImage(); }, /** * Handler of click event. * @param {Event} e Click Event object. * @private */ handleClick_: function(e) { if (this.disabled) return; // Clear all menus if the click is outside pod menu and its // button area. if (!findAncestorByClass(e.target, 'action-box-menu') && !findAncestorByClass(e.target, 'action-box-area')) { for (var i = 0, pod; pod = this.pods[i]; ++i) pod.isActionBoxMenuActive = false; } // Clears focus if not clicked on a pod and if there's more than one pod. var pod = findAncestorByClass(e.target, 'pod'); if ((!pod || pod.parentNode != this) && !this.alwaysFocusSinglePod) { this.focusPod(); } if (pod) pod.isActionBoxMenuHovered = true; // Return focus back to single pod. if (this.alwaysFocusSinglePod && !pod) { if ($('login-header-bar').contains(e.target)) return; this.focusPod(this.focusedPod_, true /* force */); this.focusedPod_.userTypeBubbleElement.classList.remove('bubble-shown'); this.focusedPod_.isActionBoxMenuHovered = false; } }, /** * Handler of mouse move event. * @param {Event} e Click Event object. * @private */ handleMouseMove_: function(e) { if (this.disabled) return; if (e.movementX == 0 && e.movementY == 0) return; // Defocus (thus hide) action box, if it is focused on a user pod // and the pointer is not hovering over it. var pod = findAncestorByClass(e.target, 'pod'); if (document.activeElement && document.activeElement.parentNode != pod && document.activeElement.classList.contains('action-box-area')) { document.activeElement.parentNode.focus(); } if (pod) pod.isActionBoxMenuHovered = true; // Hide action boxes on other user pods. for (var i = 0, p; p = this.pods[i]; ++i) if (p != pod && !p.isActionBoxMenuActive) p.isActionBoxMenuHovered = false; }, /** * Handles focus event. * @param {Event} e Focus Event object. * @private */ handleFocus_: function(e) { if (this.disabled) return; if (e.target.parentNode == this) { // Focus on a pod if (e.target.classList.contains('focused')) { if (!e.target.multiProfilesPolicyApplied) e.target.focusInput(); else e.target.userTypeBubbleElement.classList.add('bubble-shown'); } else this.focusPod(e.target); return; } var pod = findAncestorByClass(e.target, 'pod'); if (pod && pod.parentNode == this) { // Focus on a control of a pod but not on the action area button. if (!pod.classList.contains('focused')) { if (e.target.classList.contains('action-box-area') || e.target.classList.contains('remove-warning-button')) { // focusPod usually moves focus on the password input box which // triggers virtual keyboard to show up. But the focus may move to a // non text input element shortly by e.target.focus. Hence, a // virtual keyboard flicking might be observed. We need to manually // prevent focus on password input box to avoid virtual keyboard // flicking in this case. See crbug.com/396016 for details. this.focusPod(pod, false, true /* opt_skipInputFocus */); } else { this.focusPod(pod); } pod.userTypeBubbleElement.classList.remove('bubble-shown'); e.target.focus(); } return; } // Clears pod focus when we reach here. It means new focus is neither // on a pod nor on a button/input for a pod. // Do not "defocus" user pod when it is a single pod. // That means that 'focused' class will not be removed and // input field/button will always be visible. if (!this.alwaysFocusSinglePod) this.focusPod(); else { // Hide user-type-bubble in case this is one pod and we lost focus of // it. this.focusedPod_.userTypeBubbleElement.classList.remove('bubble-shown'); } }, /** * Handler of keydown event. * @param {Event} e KeyDown Event object. */ handleKeyDown: function(e) { if (this.disabled) return; var editing = e.target.tagName == 'INPUT' && e.target.value; switch (e.key) { case 'ArrowLeft': if (!editing) { if (this.focusedPod_ && this.focusedPod_.previousElementSibling) this.focusPod(this.focusedPod_.previousElementSibling); else this.focusPod(this.lastElementChild); e.stopPropagation(); } break; case 'ArrowRight': if (!editing) { if (this.focusedPod_ && this.focusedPod_.nextElementSibling) this.focusPod(this.focusedPod_.nextElementSibling); else this.focusPod(this.firstElementChild); e.stopPropagation(); } break; case 'Enter': if (this.focusedPod_) { var targetTag = e.target.tagName; if (e.target == this.focusedPod_.passwordElement || (this.focusedPod_.pinKeyboard && e.target == this.focusedPod_.pinKeyboard.inputElement) || (targetTag != 'INPUT' && targetTag != 'BUTTON' && targetTag != 'A')) { this.setActivatedPod(this.focusedPod_, e); e.stopPropagation(); } } break; case 'Escape': if (!this.alwaysFocusSinglePod) this.focusPod(); break; } }, /** * Called right after the pod row is shown. */ handleAfterShow: function() { var focusedPod = this.focusedPod_; // Without timeout changes in pods positions will be animated even though // it happened when 'flying-pods' class was disabled. setTimeout(function() { Oobe.getInstance().toggleClass('flying-pods', true); if (focusedPod) ensureTransitionEndEvent(focusedPod); }, 0); // Force input focus for user pod on show and once transition ends. if (focusedPod) { var screen = this.parentNode; var self = this; focusedPod.addEventListener('transitionend', function f(e) { focusedPod.removeEventListener('transitionend', f); focusedPod.reset(true); // Notify screen that it is ready. screen.onShow(); }); } }, /** * Called right before the pod row is shown. */ handleBeforeShow: function() { Oobe.getInstance().toggleClass('flying-pods', false); for (var event in this.listeners_) { this.ownerDocument.addEventListener( event, this.listeners_[event][0], this.listeners_[event][1]); } $('login-header-bar').buttonsTabIndex = UserPodTabOrder.HEADER_BAR; if (this.podPlacementPostponed_) { this.podPlacementPostponed_ = false; this.placePods_(); this.maybePreselectPod(); } }, /** * Called when the element is hidden. */ handleHide: function() { for (var event in this.listeners_) { this.ownerDocument.removeEventListener( event, this.listeners_[event][0], this.listeners_[event][1]); } $('login-header-bar').buttonsTabIndex = 0; }, /** * Called when a pod's user image finishes loading. */ handlePodImageLoad: function(pod) { var index = this.podsWithPendingImages_.indexOf(pod); if (index == -1) { return; } this.podsWithPendingImages_.splice(index, 1); if (this.podsWithPendingImages_.length == 0) { this.classList.remove('images-loading'); } }, /** * Preselects pod, if needed. */ maybePreselectPod: function() { var pod = this.preselectedPod; this.focusPod(pod); // Hide user-type-bubble in case all user pods are disabled and we focus // first pod. if (pod && pod.multiProfilesPolicyApplied) { pod.userTypeBubbleElement.classList.remove('bubble-shown'); } } }; return { PodRow: PodRow }; }); cr.define('cr.ui', function() { var DisplayManager = cr.ui.login.DisplayManager; /** * Maximum possible height of the #login-header-bar, including the padding * and the border. * @const {number} */ var MAX_LOGIN_HEADER_BAR_HEIGHT = 57; /** * Manages initialization of screens, transitions, and error messages. * @constructor * @extends {DisplayManager} */ function UserManager() {} cr.addSingletonGetter(UserManager); UserManager.prototype = { __proto__: DisplayManager.prototype, /** * Indicates that this is the Material Design Desktop User Manager. * @type {boolean} */ newDesktopUserManager: true, /** * Indicates whether the user pods page is visible. * @type {boolean} */ userPodsPageVisible: true, /** * @override * Overrides clientAreaSize in DisplayManager. When a new profile is created * the user pods page may not be visible yet, so user-pods cannot be * placed correctly. Therefore, we use dimensions of the #animated-pages. * @type {{width: number, height: number}} */ get clientAreaSize() { var userManagerPages = document.querySelector('user-manager-pages'); var width = userManagerPages.offsetWidth; // Deduct the maximum possible height of the #login-header-bar from the // height of #animated-pages. Result is the remaining visible height. var height = userManagerPages.offsetHeight - MAX_LOGIN_HEADER_BAR_HEIGHT; return {width: width, height: height}; } }; /** * Listens for the page change event to see if the user pods page is visible. * Updates userPodsPageVisible property accordingly and if the page is visible * re-arranges the user pods. * @param {!Event} event The event containing ID of the selected page. */ UserManager.onPageChanged_ = function(event) { var userPodsPageVisible = event.detail.page == 'user-pods-page'; cr.ui.UserManager.getInstance().userPodsPageVisible = userPodsPageVisible; if (userPodsPageVisible) $('pod-row').rebuildPods(); }; /** * Initializes the UserManager. */ UserManager.initialize = function() { cr.ui.login.DisplayManager.initialize(); login.AccountPickerScreen.register(); cr.ui.Bubble.decorate($('bubble')); signin.ProfileBrowserProxyImpl.getInstance().initializeUserManager( window.location.hash); cr.addWebUIListener('show-error-dialog', cr.ui.UserManager.showErrorDialog); }; /** * Shows the given screen. * @param {boolean} showGuest True if 'Browse as Guest' button should be * displayed. * @param {boolean} showAddPerson True if 'Add Person' button should be * displayed. */ UserManager.showUserManagerScreen = function(showGuest, showAddPerson) { UserManager.getInstance().showScreen( {id: 'account-picker', data: {disableAddUser: false}}); // Hide control options if the user does not have the right permissions. var controlBar = document.querySelector('control-bar'); controlBar.showGuest = showGuest; controlBar.showAddPerson = showAddPerson; // Disable the context menu, as the Print/Inspect element items don't // make sense when displayed as a widget. document.addEventListener('contextmenu', function(e) { e.preventDefault(); }); if (window.location.hash == '#tutorial') document.querySelector('user-manager-tutorial').startTutorial(); else if (window.location.hash == '#create-user') { document.querySelector('user-manager-pages') .setSelectedPage('create-user-page'); } }; /** * Open a new browser for the given profile. * @param {string} profilePath The profile's path. */ UserManager.launchUser = function(profilePath) { signin.ProfileBrowserProxyImpl.getInstance().launchUser(profilePath); }; /** * Disables signin UI. */ UserManager.disableSigninUI = function() { DisplayManager.disableSigninUI(); }; /** * Shows signin UI. * @param {string=} opt_email An optional email for signin UI. */ UserManager.showSigninUI = function(opt_email) { DisplayManager.showSigninUI(opt_email); }; /** * Shows sign-in error bubble. * @param {number} loginAttempts Number of login attempts tried. * @param {string} message Error message to show. * @param {string} link Text to use for help link. * @param {number} helpId Help topic Id associated with help link. */ UserManager.showSignInError = function(loginAttempts, message, link, helpId) { DisplayManager.showSignInError(loginAttempts, message, link, helpId); }; /** * Clears error bubble as well as optional menus that could be open. */ UserManager.clearErrors = function() { DisplayManager.clearErrors(); }; /** * Shows the error dialog populated with the given message. * @param {string} message Error message to show. */ UserManager.showErrorDialog = function(message) { document.querySelector('error-dialog').show(message); }; // Export return {UserManager: UserManager}; }); // Alias to Oobe for use in src/ui/login/account_picker/user_pod_row.js var Oobe = cr.ui.UserManager; // Allow selection events on components with editable text (password field) // bug (http://code.google.com/p/chromium/issues/detail?id=125863) disableTextSelectAndDrag(function(e) { var src = e.target; return src instanceof HTMLTextAreaElement || src instanceof HTMLInputElement && /text|password|search/.test(src.type); }); document.addEventListener('DOMContentLoaded', cr.ui.UserManager.initialize); document.addEventListener('change-page', cr.ui.UserManager.onPageChanged_); // Copyright 2016 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 'user-manager-pages' is the element that controls paging in the * user manager screen. */ Polymer({ is: 'user-manager-pages', properties: { /** * ID of the currently selected page. * @private {string} */ selectedPage_: {type: String, value: 'user-pods-page'}, /** * Data passed to the currently selected page. * @private {?Object} */ pageData_: {type: Object, value: null} }, listeners: {'change-page': 'onChangePage_'}, /** * Handler for the change-page event. * @param {Event} e The event containing ID of the page that is to be selected * and the optional data to be passed to the page. * @private */ onChangePage_: function(e) { this.setSelectedPage(e.detail.page, e.detail.data); }, /** * Sets the selected page. * @param {string} pageId ID of the page that is to be selected. * @param {Object=} opt_pageData Optional data to be passed to the page. */ setSelectedPage: function(pageId, opt_pageData) { this.pageData_ = opt_pageData || null; this.selectedPage_ = pageId; }, /** * This is to prevent events from propagating to the document element, which * erroneously triggers user-pod selections. * * TODO(scottchen): re-examine if its necessary for user_pod_row.js to bind * listeners on the entire document element. * * @param {!Event} e * @private */ stopPropagation_: function(e) { e.stopPropagation(); }, /** * Returns True if the first argument is present in the given set of values. * @param {string} selectedPage ID of the currently selected page. * @param {...string} var_args Pages IDs to check the first argument against. * @return {boolean} */ isPresentIn_: function(selectedPage, var_args) { var pages = Array.prototype.slice.call(arguments, 1); return pages.indexOf(selectedPage) !== -1; } }); // Copyright 2016 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 'user-manager-tutorial' is the element that controls the * tutorial steps for the user manager page. */ (function() { /** @enum {string} */ var TutorialSteps = { YOUR_CHROME: 'yourChrome', FRIENDS: 'friends', GUESTS: 'guests', COMPLETE: 'complete', NOT_YOU: 'notYou' }; Polymer({ is: 'user-manager-tutorial', properties: { /** * True if the tutorial is currently hidden. * @private {boolean} */ hidden_: {type: Boolean, value: true}, /** * Current tutorial step ID. * @type {string} */ currentStep_: {type: String, value: ''}, /** * Enum values for the step IDs. * @private {TutorialSteps} */ steps_: {readOnly: true, type: Object, value: TutorialSteps} }, /** * Determines whether a given step is displaying. * @param {string} currentStep Index of the current step * @param {string} step Name of the given step * @return {boolean} * @private */ isStepHidden_: function(currentStep, step) { return currentStep != step; }, /** * Navigates to the next step. * @param {!Event} event * @private */ onNextTap_: function(event) { var element = Polymer.dom(event).rootTarget; this.currentStep_ = element.dataset.next; }, /** * Handler for the link in the last step. Takes user to the create-profile * page in order to add a new profile. * @param {!Event} event * @private */ onAddUserTap_: function(event) { this.onDissmissTap_(); // Event is caught by user-manager-pages. this.fire('change-page', {page: 'create-user-page'}); }, /** * Starts the tutorial. */ startTutorial: function() { this.currentStep_ = TutorialSteps.YOUR_CHROME; this.hidden_ = false; // If there's only one pod, show the steps to the side of the pod. // Otherwise, center the steps and disable interacting with the pods // while the tutorial is showing. var podRow = /** @type {{focusPod: !function(), pods: !Array}} */ ($('pod-row')); this.classList.toggle('single-pod', podRow.pods.length == 1); podRow.focusPod(); // No focused pods. $('inner-container').classList.add('disabled'); }, /** * Ends the tutorial. * @private */ onDissmissTap_: function() { $('inner-container').classList.remove('disabled'); this.hidden_ = true; } }); })();

/* 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. */ .header { color: rgb(74, 142, 230); font-size: 100%; margin-bottom: 0; } #token-list { width: 100%; } tr:nth-child(odd) { background: rgb(239, 243, 255); } td.label { font-weight: bold; vertical-align: top; } td.token-actions { text-align: center; } // 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('identity_internals', function() { 'use strict'; /** * Creates an identity token item. * @param {!Object} tokenInfo Object containing token information. * @constructor */ function TokenListItem(tokenInfo) { var el = cr.doc.createElement('div'); el.data_ = tokenInfo; el.__proto__ = TokenListItem.prototype; el.decorate(); return el; } TokenListItem.prototype = { __proto__: HTMLDivElement.prototype, /** @override */ decorate: function() { this.textContent = ''; this.id = this.data_.accessToken; var table = this.ownerDocument.createElement('table'); var tbody = this.ownerDocument.createElement('tbody'); tbody.appendChild(this.createEntry_( 'accessToken', this.data_.accessToken, 'access-token')); tbody.appendChild(this.createEntry_( 'extensionName', this.data_.extensionName, 'extension-name')); tbody.appendChild(this.createEntry_( 'extensionId', this.data_.extensionId, 'extension-id')); tbody.appendChild( this.createEntry_('tokenStatus', this.data_.status, 'token-status')); tbody.appendChild(this.createEntry_( 'expirationTime', this.data_.expirationTime, 'expiration-time')); tbody.appendChild(this.createEntryForScopes_()); table.appendChild(tbody); var tfoot = this.ownerDocument.createElement('tfoot'); tfoot.appendChild(this.createButtons_()); table.appendChild(tfoot); this.appendChild(table); }, /** * Creates an entry for a single property of the token. * @param {string} label An i18n label of the token's property name. * @param {string} value A value of the token property. * @param {string} accessor Additional class to tag the field for testing. * @return {HTMLElement} An HTML element with the property name and value. */ createEntry_: function(label, value, accessor) { var row = this.ownerDocument.createElement('tr'); var labelField = this.ownerDocument.createElement('td'); labelField.classList.add('label'); labelField.textContent = loadTimeData.getString(label); row.appendChild(labelField); var valueField = this.ownerDocument.createElement('td'); valueField.classList.add('value'); valueField.classList.add(accessor); valueField.textContent = value; row.appendChild(valueField); return row; }, /** * Creates an entry for a list of token scopes. * @return {!HTMLElement} An HTML element with scopes. */ createEntryForScopes_: function() { var row = this.ownerDocument.createElement('tr'); var labelField = this.ownerDocument.createElement('td'); labelField.classList.add('label'); labelField.textContent = loadTimeData.getString('scopes'); row.appendChild(labelField); var valueField = this.ownerDocument.createElement('td'); valueField.classList.add('value'); valueField.classList.add('scope-list'); this.data_.scopes.forEach(function(scope) { valueField.appendChild(this.ownerDocument.createTextNode(scope)); valueField.appendChild(this.ownerDocument.createElement('br')); }, this); row.appendChild(valueField); return row; }, /** * Creates buttons for the token. * @return {HTMLElement} An HTML element with actionable buttons for the * token. */ createButtons_: function() { var row = this.ownerDocument.createElement('tr'); var buttonHolder = this.ownerDocument.createElement('td'); buttonHolder.colSpan = 2; buttonHolder.classList.add('token-actions'); buttonHolder.appendChild(this.createRevokeButton_()); row.appendChild(buttonHolder); return row; }, /** * Creates a revoke button with an event sending a revoke token message * to the controller. * @return {!HTMLButtonElement} The created revoke button. * @private */ createRevokeButton_: function() { var revokeButton = this.ownerDocument.createElement('button'); revokeButton.classList.add('revoke-button'); revokeButton.addEventListener('click', function() { chrome.send( 'identityInternalsRevokeToken', [this.data_.extensionId, this.data_.accessToken]); }.bind(this)); revokeButton.textContent = loadTimeData.getString('revoke'); return revokeButton; }, }; /** * Creates a new list of identity tokens. * @param {Object=} opt_propertyBag Optional properties. * @constructor * @extends {cr.ui.div} */ var TokenList = cr.ui.define('div'); TokenList.prototype = { __proto__: HTMLDivElement.prototype, /** @override */ decorate: function() { this.textContent = ''; this.showTokenNodes_(); }, /** * Populates the list of tokens. */ showTokenNodes_: function() { this.data_.forEach(function(tokenInfo) { this.appendChild(new TokenListItem(tokenInfo)); }, this); }, /** * Removes a token node related to the specifed token ID from both the * internals data source as well as the user internface. * @param {string} accessToken The id of the token to remove. * @private */ removeTokenNode_: function(accessToken) { var tokenIndex; for (var index = 0; index < this.data_.length; index++) { if (this.data_[index].accessToken == accessToken) { tokenIndex = index; break; } } // Remove from the data_ source if token found. if (tokenIndex) this.data_.splice(tokenIndex, 1); // Remove from the user interface. var tokenNode = $(accessToken); if (tokenNode) this.removeChild(tokenNode); }, }; var tokenList; /** * Initializes the UI by asking the contoller for list of identity tokens. */ function initialize() { chrome.send('identityInternalsGetTokens'); tokenList = $('token-list'); tokenList.data_ = []; tokenList.__proto__ = TokenList.prototype; tokenList.decorate(); } /** * Callback function accepting a list of tokens to be displayed. * @param {!Token[]} tokens A list of tokens to be displayed */ function returnTokens(tokens) { tokenList.data_ = tokens; tokenList.showTokenNodes_(); } /** * Callback function that removes a token from UI once it has been revoked. * @param {!Array} accessTokens Array with a single element, which is * an access token to be removed. */ function tokenRevokeDone(accessTokens) { assert(accessTokens.length > 0); tokenList.removeTokenNode_(accessTokens[0]); } // Return an object with all of the exports. return { initialize: initialize, returnTokens: returnTokens, tokenRevokeDone: tokenRevokeDone, }; }); document.addEventListener('DOMContentLoaded', identity_internals.initialize); ;o0 ݂D:E ZgdPbLyC~5hkAMg$w?H^pa ^|Q^OBՎrƎ-aGO[@F XP(^PkQD$鴗wyǑBYP[ @JuAn옲.puemʈ5 fjg*ًͤf|gȮ_֧i43:|2p0FJpAKRb;P=wui=0?;m F{9n9; oPJKs1c pjKBƚ; ާjkw}Xc#gGk!AZzy>И}ryYK9_ dog蘱ƿDvi# WSFN$"HO%Ihc<0nn┢Տ6$J`|㧊:'9Wن}ϵ#YWomo#0h 3tMfFm&u~K^/EƩ=S*M*Ѽ dk:++^Bex'DE&y~]+8/6FN4"Fá`zsfeUmF!8>knHWSd_3N FL7IPs i5LT,_tnAn'!sd Bg]p&hP{E-8E|⒘ݙ?vھ$aEǻzF&{ǻ~ڽ9)=nĦmLlh-Y aM[`<ΤI}6Nݳ5p2IȒKY#C+zn0 ~ ŀR7h!=u]]=lѶY$97GIjv ǟS!{]7<47Vw-\w!pm $Q"S:_" z $|*B %ZNPJ *m9p-TNϲ_@wFWP|+XFMvLګgEZk͊ϔUgwvi"va+C.qR)mafhJVd)8YPJh.ve7|:Q 3^=gPKk6p!V VqSk";d! ;Vذ=f4x`|yTǚ.LR٠s,*EWOU~w8M`#?_/ܖvh;^IfhX:瞔qw`I { "name": "Smart Lock", "description": "This app allows you to unlock your device when in proximity to your phone.", "version": "1.0", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqOUeUl1nC6qTz6WwVUIaAJ4ukXVzgeCAumX4TZlCHFk5DLHImHLDBxakyVGaQFLS9iEQ3tDTsJLIoA+FkbWKNX7bvDW/qM89CeVNZsIZRGw898m8J78N6dJHwP9aZSI8CpoMK2KvjANpuj1tdWs1OM6v65zRUu6y4Mq876dr5AcPiuznGxl8jekagBwGu8jqMySsJxLazj/EfQ3W1E7mpyHd0Z4C1qNwJoFlUQeMjn6gfPZqa06BLU6YznzCUesiyjFK3d1vzbN54ZkVxhcA6ekwLKYLqKykBFLmIQG0gkNNePzcGXju8p34dGJgkcZw0sOXrtNaLSe1su0zfcniIwIDAQAB", "oauth2": { "client_id": "383927464186-v05g3e5emhrrblqmpnvq7666jktlpc7q.apps.googleusercontent.com", "auto_approve": true, "scopes": [ "https://www.googleapis.com/auth/proximity_auth", "https://www.googleapis.com/auth/cryptauth" ] }, "permissions": [ // Public APIs: "alarms", "browser", "gcm", "identity", "notifications", "storage", "system.display", // Private APIs: "bluetoothPrivate", "chromeosInfoPrivate", "easyUnlockPrivate", "feedbackPrivate", "metricsPrivate", "preferencesPrivate", "screenlockPrivate", "systemPrivate" ], "app": { "background": { "scripts": ["easy_unlock_background.js"] } }, "bluetooth": { "socket" : true, "low_energy" : true, "uuids": [ "704EE561-3782-405A-A14B-2D47A2DDCDDF", // Unlock UUID "29422880-D56D-11E3-9C1A-0800200C9A66" // Setup UUID ] }, "offline_enabled": true, "display_in_launcher": false, "icons": { "32": "icons/easyunlock_app_icon_32.png", "48": "icons/easyunlock_app_icon_48.png", "64": "icons/easyunlock_app_icon_64.png", "96": "icons/easyunlock_app_icon_96.png", "128": "icons/easyunlock_app_icon_128.png", "256": "icons/easyunlock_app_icon_256.png" } } { "name": "Smart Lock", "description": "This app allows you to sign-in to a device when in proximity to your phone.", "version": "1.1", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqOUeUl1nC6qTz6WwVUIaAJ4ukXVzgeCAumX4TZlCHFk5DLHImHLDBxakyVGaQFLS9iEQ3tDTsJLIoA+FkbWKNX7bvDW/qM89CeVNZsIZRGw898m8J78N6dJHwP9aZSI8CpoMK2KvjANpuj1tdWs1OM6v65zRUu6y4Mq876dr5AcPiuznGxl8jekagBwGu8jqMySsJxLazj/EfQ3W1E7mpyHd0Z4C1qNwJoFlUQeMjn6gfPZqa06BLU6YznzCUesiyjFK3d1vzbN54ZkVxhcA6ekwLKYLqKykBFLmIQG0gkNNePzcGXju8p34dGJgkcZw0sOXrtNaLSe1su0zfcniIwIDAQAB", "permissions": [ // Public APIs: "alarms", "browser", "gcm", "identity", "notifications", "storage", "system.display", // Private APIs: "bluetoothPrivate", "chromeosInfoPrivate", "easyUnlockPrivate", "feedbackPrivate", "metricsPrivate", "preferencesPrivate", "screenlockPrivate", "systemPrivate" ], "app": { "background": { "scripts": ["easy_unlock_background.js"] } }, "bluetooth": { "socket" : true, "low_energy" : true, "uuids": [ "704EE561-3782-405A-A14B-2D47A2DDCDDF" // Unlock UUID ] }, "offline_enabled": true, "display_in_launcher": false, "incognito": "split", "icons": { "32": "icons/easyunlock_app_icon_32.png", "48": "icons/easyunlock_app_icon_48.png", "64": "icons/easyunlock_app_icon_64.png", "96": "icons/easyunlock_app_icon_96.png", "128": "icons/easyunlock_app_icon_128.png", "256": "icons/easyunlock_app_icon_256.png" } } /* 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 { --dialog-padding-end: 26px; --dialog-padding-start: 16px; --dialog-width: 340px; --navigation-icon-button-size: 36px; --non-navigation-icon-size: 16px; -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; font-family: 'Roboto', 'Noto', sans-serif; } .button { color: var(--paper-blue-700); cursor: pointer; text-align: center; } [hidden] { display: none !important; } .ellipsis { overflow: hidden; padding: 0 1%; text-overflow: ellipsis; white-space: nowrap; } /* 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 { font-size: 0.75em; margin: 0; } #media-router-container { background-color: white; box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.14), 0 1px 8px 0 rgba(0, 0, 0, 0.12), 0 3px 3px -2px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; margin-bottom: 1px; width: calc(var(--dialog-width) - 1px); } // 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. // Any strings used here will already be localized. Values such as // CastMode.type or IDs will be defined elsewhere and determined later. cr.exportPath('media_router'); /** * This corresponds to the C++ MediaCastMode, with the exception of AUTO. * See below for details. Note to support fast bitset operations, the values * here are (1 << [corresponding value in MR]). * @enum {number} */ media_router.CastModeType = { // Note: AUTO mode is only used to configure the sink list container to show // all sinks. Individual sinks are configured with a specific cast mode // (PRESENTATION, TAB_MIRROR, DESKTOP_MIRROR). AUTO: -1, PRESENTATION: 0x1, TAB_MIRROR: 0x2, DESKTOP_MIRROR: 0x4, LOCAL_FILE: 0x8, }; /** * The ESC key maps to KeyboardEvent.key value 'Escape'. * @const {string} */ media_router.KEY_ESC = 'Escape'; /** * This corresponds to the C++ MediaRouterMetrics * MediaRouterRouteCreationOutcome. * @enum {number} */ media_router.MediaRouterRouteCreationOutcome = { SUCCESS: 0, FAILURE_NO_ROUTE: 1, FAILURE_INVALID_SINK: 2, }; /** * This corresponds to the C++ MediaRouterMetrics MediaRouterUserAction. * @enum {number} */ media_router.MediaRouterUserAction = { CHANGE_MODE: 0, START_LOCAL: 1, STOP_LOCAL: 2, CLOSE: 3, STATUS_REMOTE: 4, REPLACE_LOCAL_ROUTE: 5, }; /** * The possible states of the Media Router dialog. Used to determine which * components to show. * @enum {string} */ media_router.MediaRouterView = { CAST_MODE_LIST: 'cast-mode-list', FILTER: 'filter', ISSUE: 'issue', ROUTE_DETAILS: 'route-details', SINK_LIST: 'sink-list', }; /** * The minimum number of sinks to have to enable the search input strictly for * filtering (i.e. the Media Router doesn't support search so the search input * only filters existing sinks). * @const {number} */ media_router.MINIMUM_SINKS_FOR_SEARCH = 20; /** * The states that media can be in. * @enum {number} */ media_router.PlayState = { PLAYING: 0, PAUSED: 1, BUFFERING: 2, }; /** * This corresponds to the C++ MediaSink IconType, and the order must stay in * sync. * @enum {number} */ media_router.SinkIconType = { CAST: 0, CAST_AUDIO_GROUP: 1, CAST_AUDIO: 2, MEETING: 3, HANGOUT: 4, EDUCATION: 5, WIRED_DISPLAY: 6, GENERIC: 7, }; /** * @enum {string} */ media_router.SinkStatus = { IDLE: 'idle', ACTIVE: 'active', REQUEST_PENDING: 'request_pending' }; cr.define('media_router', function() { 'use strict'; /** * @param {number} type The type of cast mode. * @param {string} description The description of the cast mode. * @param {?string} host The hostname of the site to cast. * @param {boolean} isForced True if the mode is forced. * @constructor * @struct */ var CastMode = function(type, description, host, isForced) { /** @type {number} */ this.type = type; /** @type {string} */ this.description = description; /** @type {?string} */ this.host = host || null; /** @type {boolean} */ this.isForced = isForced; }; /** * Placeholder object for AUTO cast mode. See comment in CastModeType. * @const {!media_router.CastMode} */ var AUTO_CAST_MODE = new CastMode( media_router.CastModeType.AUTO, loadTimeData.getString('autoCastMode'), null, false); /** * @param {number} id The ID of this issue. * @param {string} title The issue title. * @param {string} message The issue message. * @param {number} defaultActionType The type of default action. * @param {number|undefined} secondaryActionType The type of optional action. * @param {?string} routeId The route ID to which this issue * pertains. If not set, this is a global issue. * @param {boolean} isBlocking True if this issue blocks other UI. * @param {?number} helpPageId The numeric help center ID. * @constructor * @struct */ var Issue = function( id, title, message, defaultActionType, secondaryActionType, routeId, isBlocking, helpPageId) { /** @type {number} */ this.id = id; /** @type {string} */ this.title = title; /** @type {string} */ this.message = message; /** @type {number} */ this.defaultActionType = defaultActionType; /** @type {number|undefined} */ this.secondaryActionType = secondaryActionType; /** @type {?string} */ this.routeId = routeId; /** @type {boolean} */ this.isBlocking = isBlocking; /** @type {?number} */ this.helpPageId = helpPageId; }; /** * @param {string} id The media route ID. * @param {string} sinkId The ID of the media sink running this route. * @param {string} description The short description of this route. * @param {?number} tabId The ID of the tab in which web app is running and * accessing the route. * @param {boolean} isLocal True if this is a locally created route. * @param {boolean} canJoin True if this route can be joined. * @param {?string} customControllerPath non-empty if this route has custom * controller. * @constructor * @struct */ var Route = function( id, sinkId, description, tabId, isLocal, canJoin, customControllerPath) { /** @type {string} */ this.id = id; /** @type {string} */ this.sinkId = sinkId; /** @type {string} */ this.description = description; /** @type {?number} */ this.tabId = tabId; /** @type {boolean} */ this.isLocal = isLocal; /** @type {boolean} */ this.canJoin = canJoin; /** @type {number|undefined} */ this.currentCastMode = undefined; /** @type {?string} */ this.customControllerPath = customControllerPath; /** @type {boolean} */ this.supportsWebUiController = false; }; /** * @param {string} title The title of the route. * @param {string} description A description for the route. * @param {boolean} canPlayPause Whether the route can be played/paused. * @param {boolean} canMute Whether the route can be muted/unmuted. * @param {boolean} canSetVolume Whether the route volume can be changed. * @param {boolean} canSeek Whether the route's playback position can be * changed. * @param {boolean} isPaused Whether the route is paused. * @param {boolean} isMuted Whether the route is muted. * @param {number} volume The route's volume, between 0 and 1. * @param {number} duration The route's duration in seconds. * @param {number} currentTime The route's current position in seconds. * Must not be greater than |duration|. * @param {!{mediaRemotingEnabled: boolean}=} mirroringExtraData Only set for * mirroring routes. * @param {!{localPresent: boolean}=} hangoutsExtraData Only set for Hangouts * routes. * @constructor * @struct */ var RouteStatus = function( title = '', description = '', canPlayPause = false, canMute = false, canSetVolume = false, canSeek = false, playState = media_router.PlayState.PLAYING, isPaused = false, isMuted = false, volume = 0, duration = 0, currentTime = 0, hangoutsExtraData = undefined, mirroringExtraData = undefined) { /** @type {string} */ this.title = title; /** @type {string} */ this.description = description; /** @type {boolean} */ this.canPlayPause = canPlayPause; /** @type {boolean} */ this.canMute = canMute; /** @type {boolean} */ this.canSetVolume = canSetVolume; /** @type {boolean} */ this.canSeek = canSeek; /** @type {media_router.PlayState} */ this.playState = playState; /** @type {boolean} */ this.isMuted = isMuted; /** @type {number} */ this.volume = volume; /** @type {number} */ this.duration = duration; /** @type {number} */ this.currentTime = currentTime; /** @type {!{localPresent: boolean}|undefined} */ this.hangoutsExtraData = hangoutsExtraData; /** @type {!{mediaRemotingEnabled: boolean}|undefined} */ this.mirroringExtraData = mirroringExtraData; }; /** * @param {string} id The ID of the media sink. * @param {string} name The name of the sink. * @param {?string} description Optional description of the sink. * @param {?string} domain Optional domain of the sink. * @param {media_router.SinkIconType} iconType the type of icon for the sink. * @param {media_router.SinkStatus} status The readiness state of the sink. * @param {number} castModes Bitset of cast modes compatible with the sink. * @constructor * @struct */ var Sink = function( id, name, description, domain, iconType, status, castModes) { /** @type {string} */ this.id = id; /** @type {string} */ this.name = name; /** @type {?string} */ this.description = description; /** @type {?string} */ this.domain = domain; /** @type {!media_router.SinkIconType} */ this.iconType = iconType; /** @type {!media_router.SinkStatus} */ this.status = status; /** @type {number} */ this.castModes = castModes; /** @type {boolean} */ this.isPseudoSink = false; }; /** * @param {number} tabId The current tab ID. * @param {string} domain The domain of the current tab. * @constructor * @struct */ var TabInfo = function(tabId, domain) { /** @type {number} */ this.tabId = tabId; /** @type {string} */ this.domain = domain; }; return { AUTO_CAST_MODE: AUTO_CAST_MODE, CastMode: CastMode, Issue: Issue, Route: Route, RouteStatus: RouteStatus, Sink: Sink, TabInfo: TabInfo, }; }); // 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. // // Copyright 2017 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. // API invoked by this UI to communicate with the browser WebUI message handler. cr.define('media_router.browserApi', function() { 'use strict'; /** * Indicates that the user has acknowledged the first run flow. * * @param {boolean} optedIntoCloudServices Whether or not the user opted into * cloud services. */ function acknowledgeFirstRunFlow(optedIntoCloudServices) { chrome.send('acknowledgeFirstRunFlow', [optedIntoCloudServices]); } /** * Acts on the given issue. * * @param {number} issueId * @param {number} actionType Type of action that the user clicked. * @param {?number} helpPageId The numeric help center ID. */ function actOnIssue(issueId, actionType, helpPageId) { chrome.send( 'actOnIssue', [{issueId: issueId, actionType: actionType, helpPageId: helpPageId}]); } /** * Modifies |route| by changing its source to the one identified by * |selectedCastMode|. * * @param {!media_router.Route} route The route being modified. * @param {number} selectedCastMode The value of the cast mode the user * selected. */ function changeRouteSource(route, selectedCastMode) { chrome.send( 'requestRoute', [{sinkId: route.sinkId, selectedCastMode: selectedCastMode}]); } /** * Closes the dialog. * * @param {boolean} pressEscToClose Whether the user pressed ESC to close the * dialog. */ function closeDialog(pressEscToClose) { chrome.send('closeDialog', [pressEscToClose]); } /** * Closes the given route. * * @param {!media_router.Route} route */ function closeRoute(route) { chrome.send('closeRoute', [{routeId: route.id, isLocal: route.isLocal}]); } /** * Joins the given route. * * @param {!media_router.Route} route */ function joinRoute(route) { chrome.send('joinRoute', [{sinkId: route.sinkId, routeId: route.id}]); } /** * Indicates that the initial data has been received. */ function onInitialDataReceived() { chrome.send('onInitialDataReceived'); } /** * Reports that the route details view was closed. */ function onMediaControllerClosed() { chrome.send('onMediaControllerClosed'); } /** * Reports that the route details view was opened for |routeId|. * * @param {string} routeId */ function onMediaControllerAvailable(routeId) { chrome.send('onMediaControllerAvailable', [{routeId: routeId}]); } /** * Sends a command to pause the route shown in the route details view. */ function pauseCurrentMedia() { chrome.send('pauseCurrentMedia'); } /** * Sends a command to play the route shown in the route details view. */ function playCurrentMedia() { chrome.send('playCurrentMedia'); } /** * Reports when the user clicks outside the dialog. */ function reportBlur() { chrome.send('reportBlur'); } /** * Reports the index of the selected sink. * * @param {number} sinkIndex */ function reportClickedSinkIndex(sinkIndex) { chrome.send('reportClickedSinkIndex', [sinkIndex]); } /** * Reports that the user used the filter input. */ function reportFilter() { chrome.send('reportFilter'); } /** * Reports the initial dialog view. * * @param {string} view */ function reportInitialState(view) { chrome.send('reportInitialState', [view]); } /** * Reports the initial action the user took. * * @param {number} action */ function reportInitialAction(action) { chrome.send('reportInitialAction', [action]); } /** * Reports the navigation to the specified view. * * @param {string} view */ function reportNavigateToView(view) { chrome.send('reportNavigateToView', [view]); } /** * Reports whether or not a route was created successfully. * * @param {boolean} success */ function reportRouteCreation(success) { chrome.send('reportRouteCreation', [success]); } /** * Reports the outcome of a create route response. * * @param {number} outcome */ function reportRouteCreationOutcome(outcome) { chrome.send('reportRouteCreationOutcome', [outcome]); } /** * Reports the cast mode that the user selected. * * @param {number} castModeType */ function reportSelectedCastMode(castModeType) { chrome.send('reportSelectedCastMode', [castModeType]); } /** * Reports the current number of sinks. * * @param {number} sinkCount */ function reportSinkCount(sinkCount) { chrome.send('reportSinkCount', [sinkCount]); } /** * Reports the time it took for the user to select a sink after the sink list * is populated and shown. * * @param {number} timeMs */ function reportTimeToClickSink(timeMs) { chrome.send('reportTimeToClickSink', [timeMs]); } /** * Reports the time, in ms, it took for the user to close the dialog without * taking any other action. * * @param {number} timeMs */ function reportTimeToInitialActionClose(timeMs) { chrome.send('reportTimeToInitialActionClose', [timeMs]); } /** * Reports the time, in ms, it took the WebUI route controller to load media * status info. * * @param {number} timeMs */ function reportWebUIRouteControllerLoaded(timeMs) { chrome.send('reportWebUIRouteControllerLoaded', [timeMs]); } /** * Requests data to initialize the WebUI with. * The data will be returned via media_router.ui.setInitialData. */ function requestInitialData() { chrome.send('requestInitialData'); } /** * Requests that a media route be started with the given sink. * * @param {string} sinkId The sink ID. * @param {number} selectedCastMode The value of the cast mode the user * selected. */ function requestRoute(sinkId, selectedCastMode) { chrome.send( 'requestRoute', [{sinkId: sinkId, selectedCastMode: selectedCastMode}]); } /** * Requests that the media router search all providers for a sink matching * |searchCriteria| that can be used with the media source associated with the * cast mode |selectedCastMode|. If such a sink is found, a route is also * created between the sink and the media source. * * @param {string} sinkId Sink ID of the pseudo sink generating the request. * @param {string} searchCriteria Search criteria for the route providers. * @param {string} domain User's current hosted domain. * @param {number} selectedCastMode The value of the cast mode to be used with * the sink. */ function searchSinksAndCreateRoute( sinkId, searchCriteria, domain, selectedCastMode) { chrome.send('searchSinksAndCreateRoute', [{ sinkId: sinkId, searchCriteria: searchCriteria, domain: domain, selectedCastMode: selectedCastMode }]); } /** * Sends a command to seek the route shown in the route details view. * * @param {number} time The new current time in seconds. */ function seekCurrentMedia(time) { chrome.send('seekCurrentMedia', [{time: time}]); } /** * Sends a command to open a file dialog and allow the user to choose a local * media file. */ function selectLocalMediaFile() { chrome.send('selectLocalMediaFile'); } /** * Sends a command to mute or unmute the route shown in the route details * view. * * @param {boolean} mute Mute the route if true, unmute it if false. */ function setCurrentMediaMute(mute) { chrome.send('setCurrentMediaMute', [{mute: mute}]); } /** * Sends a command to change the volume of the route shown in the route * details view. * * @param {number} volume The volume between 0 and 1. */ function setCurrentMediaVolume(volume) { chrome.send('setCurrentMediaVolume', [{volume: volume}]); } /** * Sets the local present mode of the Hangouts associated with the current * route. * @param {boolean} localPresent */ function setHangoutsLocalPresent(localPresent) { chrome.send('hangouts.setLocalPresent', [localPresent]); } /** * Sends a command to change the Media Remoting enabled value associated with * current route. * @param {boolean} enabled */ function setMediaRemotingEnabled(enabled) { chrome.send('setMediaRemotingEnabled', [enabled]); } return { acknowledgeFirstRunFlow: acknowledgeFirstRunFlow, actOnIssue: actOnIssue, changeRouteSource: changeRouteSource, closeDialog: closeDialog, closeRoute: closeRoute, joinRoute: joinRoute, onInitialDataReceived: onInitialDataReceived, onMediaControllerClosed: onMediaControllerClosed, onMediaControllerAvailable: onMediaControllerAvailable, pauseCurrentMedia: pauseCurrentMedia, playCurrentMedia: playCurrentMedia, reportBlur: reportBlur, reportClickedSinkIndex: reportClickedSinkIndex, reportFilter: reportFilter, reportInitialAction: reportInitialAction, reportInitialState: reportInitialState, reportNavigateToView: reportNavigateToView, reportRouteCreation: reportRouteCreation, reportRouteCreationOutcome: reportRouteCreationOutcome, reportSelectedCastMode: reportSelectedCastMode, reportSinkCount: reportSinkCount, reportTimeToClickSink: reportTimeToClickSink, reportTimeToInitialActionClose: reportTimeToInitialActionClose, reportWebUIRouteControllerLoaded: reportWebUIRouteControllerLoaded, requestInitialData: requestInitialData, requestRoute: requestRoute, searchSinksAndCreateRoute: searchSinksAndCreateRoute, seekCurrentMedia: seekCurrentMedia, selectLocalMediaFile: selectLocalMediaFile, setCurrentMediaMute: setCurrentMediaMute, setCurrentMediaVolume: setCurrentMediaVolume, setHangoutsLocalPresent: setHangoutsLocalPresent, setMediaRemotingEnabled: setMediaRemotingEnabled }; }); // // 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. // Any strings used here will already be localized. Values such as // CastMode.type or IDs will be defined elsewhere and determined later. cr.exportPath('media_router'); /** * This corresponds to the C++ MediaCastMode, with the exception of AUTO. * See below for details. Note to support fast bitset operations, the values * here are (1 << [corresponding value in MR]). * @enum {number} */ media_router.CastModeType = { // Note: AUTO mode is only used to configure the sink list container to show // all sinks. Individual sinks are configured with a specific cast mode // (PRESENTATION, TAB_MIRROR, DESKTOP_MIRROR). AUTO: -1, PRESENTATION: 0x1, TAB_MIRROR: 0x2, DESKTOP_MIRROR: 0x4, LOCAL_FILE: 0x8, }; /** * The ESC key maps to KeyboardEvent.key value 'Escape'. * @const {string} */ media_router.KEY_ESC = 'Escape'; /** * This corresponds to the C++ MediaRouterMetrics * MediaRouterRouteCreationOutcome. * @enum {number} */ media_router.MediaRouterRouteCreationOutcome = { SUCCESS: 0, FAILURE_NO_ROUTE: 1, FAILURE_INVALID_SINK: 2, }; /** * This corresponds to the C++ MediaRouterMetrics MediaRouterUserAction. * @enum {number} */ media_router.MediaRouterUserAction = { CHANGE_MODE: 0, START_LOCAL: 1, STOP_LOCAL: 2, CLOSE: 3, STATUS_REMOTE: 4, REPLACE_LOCAL_ROUTE: 5, }; /** * The possible states of the Media Router dialog. Used to determine which * components to show. * @enum {string} */ media_router.MediaRouterView = { CAST_MODE_LIST: 'cast-mode-list', FILTER: 'filter', ISSUE: 'issue', ROUTE_DETAILS: 'route-details', SINK_LIST: 'sink-list', }; /** * The minimum number of sinks to have to enable the search input strictly for * filtering (i.e. the Media Router doesn't support search so the search input * only filters existing sinks). * @const {number} */ media_router.MINIMUM_SINKS_FOR_SEARCH = 20; /** * The states that media can be in. * @enum {number} */ media_router.PlayState = { PLAYING: 0, PAUSED: 1, BUFFERING: 2, }; /** * This corresponds to the C++ MediaSink IconType, and the order must stay in * sync. * @enum {number} */ media_router.SinkIconType = { CAST: 0, CAST_AUDIO_GROUP: 1, CAST_AUDIO: 2, MEETING: 3, HANGOUT: 4, EDUCATION: 5, WIRED_DISPLAY: 6, GENERIC: 7, }; /** * @enum {string} */ media_router.SinkStatus = { IDLE: 'idle', ACTIVE: 'active', REQUEST_PENDING: 'request_pending' }; cr.define('media_router', function() { 'use strict'; /** * @param {number} type The type of cast mode. * @param {string} description The description of the cast mode. * @param {?string} host The hostname of the site to cast. * @param {boolean} isForced True if the mode is forced. * @constructor * @struct */ var CastMode = function(type, description, host, isForced) { /** @type {number} */ this.type = type; /** @type {string} */ this.description = description; /** @type {?string} */ this.host = host || null; /** @type {boolean} */ this.isForced = isForced; }; /** * Placeholder object for AUTO cast mode. See comment in CastModeType. * @const {!media_router.CastMode} */ var AUTO_CAST_MODE = new CastMode( media_router.CastModeType.AUTO, loadTimeData.getString('autoCastMode'), null, false); /** * @param {number} id The ID of this issue. * @param {string} title The issue title. * @param {string} message The issue message. * @param {number} defaultActionType The type of default action. * @param {number|undefined} secondaryActionType The type of optional action. * @param {?string} routeId The route ID to which this issue * pertains. If not set, this is a global issue. * @param {boolean} isBlocking True if this issue blocks other UI. * @param {?number} helpPageId The numeric help center ID. * @constructor * @struct */ var Issue = function( id, title, message, defaultActionType, secondaryActionType, routeId, isBlocking, helpPageId) { /** @type {number} */ this.id = id; /** @type {string} */ this.title = title; /** @type {string} */ this.message = message; /** @type {number} */ this.defaultActionType = defaultActionType; /** @type {number|undefined} */ this.secondaryActionType = secondaryActionType; /** @type {?string} */ this.routeId = routeId; /** @type {boolean} */ this.isBlocking = isBlocking; /** @type {?number} */ this.helpPageId = helpPageId; }; /** * @param {string} id The media route ID. * @param {string} sinkId The ID of the media sink running this route. * @param {string} description The short description of this route. * @param {?number} tabId The ID of the tab in which web app is running and * accessing the route. * @param {boolean} isLocal True if this is a locally created route. * @param {boolean} canJoin True if this route can be joined. * @param {?string} customControllerPath non-empty if this route has custom * controller. * @constructor * @struct */ var Route = function( id, sinkId, description, tabId, isLocal, canJoin, customControllerPath) { /** @type {string} */ this.id = id; /** @type {string} */ this.sinkId = sinkId; /** @type {string} */ this.description = description; /** @type {?number} */ this.tabId = tabId; /** @type {boolean} */ this.isLocal = isLocal; /** @type {boolean} */ this.canJoin = canJoin; /** @type {number|undefined} */ this.currentCastMode = undefined; /** @type {?string} */ this.customControllerPath = customControllerPath; /** @type {boolean} */ this.supportsWebUiController = false; }; /** * @param {string} title The title of the route. * @param {string} description A description for the route. * @param {boolean} canPlayPause Whether the route can be played/paused. * @param {boolean} canMute Whether the route can be muted/unmuted. * @param {boolean} canSetVolume Whether the route volume can be changed. * @param {boolean} canSeek Whether the route's playback position can be * changed. * @param {boolean} isPaused Whether the route is paused. * @param {boolean} isMuted Whether the route is muted. * @param {number} volume The route's volume, between 0 and 1. * @param {number} duration The route's duration in seconds. * @param {number} currentTime The route's current position in seconds. * Must not be greater than |duration|. * @param {!{mediaRemotingEnabled: boolean}=} mirroringExtraData Only set for * mirroring routes. * @param {!{localPresent: boolean}=} hangoutsExtraData Only set for Hangouts * routes. * @constructor * @struct */ var RouteStatus = function( title = '', description = '', canPlayPause = false, canMute = false, canSetVolume = false, canSeek = false, playState = media_router.PlayState.PLAYING, isPaused = false, isMuted = false, volume = 0, duration = 0, currentTime = 0, hangoutsExtraData = undefined, mirroringExtraData = undefined) { /** @type {string} */ this.title = title; /** @type {string} */ this.description = description; /** @type {boolean} */ this.canPlayPause = canPlayPause; /** @type {boolean} */ this.canMute = canMute; /** @type {boolean} */ this.canSetVolume = canSetVolume; /** @type {boolean} */ this.canSeek = canSeek; /** @type {media_router.PlayState} */ this.playState = playState; /** @type {boolean} */ this.isMuted = isMuted; /** @type {number} */ this.volume = volume; /** @type {number} */ this.duration = duration; /** @type {number} */ this.currentTime = currentTime; /** @type {!{localPresent: boolean}|undefined} */ this.hangoutsExtraData = hangoutsExtraData; /** @type {!{mediaRemotingEnabled: boolean}|undefined} */ this.mirroringExtraData = mirroringExtraData; }; /** * @param {string} id The ID of the media sink. * @param {string} name The name of the sink. * @param {?string} description Optional description of the sink. * @param {?string} domain Optional domain of the sink. * @param {media_router.SinkIconType} iconType the type of icon for the sink. * @param {media_router.SinkStatus} status The readiness state of the sink. * @param {number} castModes Bitset of cast modes compatible with the sink. * @constructor * @struct */ var Sink = function( id, name, description, domain, iconType, status, castModes) { /** @type {string} */ this.id = id; /** @type {string} */ this.name = name; /** @type {?string} */ this.description = description; /** @type {?string} */ this.domain = domain; /** @type {!media_router.SinkIconType} */ this.iconType = iconType; /** @type {!media_router.SinkStatus} */ this.status = status; /** @type {number} */ this.castModes = castModes; /** @type {boolean} */ this.isPseudoSink = false; }; /** * @param {number} tabId The current tab ID. * @param {string} domain The domain of the current tab. * @constructor * @struct */ var TabInfo = function(tabId, domain) { /** @type {number} */ this.tabId = tabId; /** @type {string} */ this.domain = domain; }; return { AUTO_CAST_MODE: AUTO_CAST_MODE, CastMode: CastMode, Issue: Issue, Route: Route, RouteStatus: RouteStatus, Sink: Sink, TabInfo: TabInfo, }; }); // // 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. // API invoked by the browser MediaRouterWebUIMessageHandler to communicate // with this UI. cr.define('media_router.ui', function() { 'use strict'; // The media-router-container element. var container = null; // The media-router-header element. var header = null; // The route-controls element. Is null if the route details view isn't open. var routeControls = null; /** * Handles response of previous create route attempt. * * @param {string} sinkId The ID of the sink to which the Media Route was * creating a route. * @param {?media_router.Route} route The newly created route that * corresponds to the sink if route creation succeeded; null otherwise. * @param {boolean} isForDisplay Whether or not |route| is for display. */ function onCreateRouteResponseReceived(sinkId, route, isForDisplay) { container.onCreateRouteResponseReceived(sinkId, route, isForDisplay); } /** * Called when the route controller for the route that is currently selected * is invalidated. */ function onRouteControllerInvalidated() { container.onRouteControllerInvalidated(); } /** * Handles the search response by forwarding |sinkId| to the container. * * @param {string} sinkId The ID of the sink found by search. */ function receiveSearchResult(sinkId) { container.onReceiveSearchResult(sinkId); } /** * Sets the cast mode list. * * @param {!Array} castModeList */ function setCastModeList(castModeList) { container.castModeList = castModeList; } /** * Sets |container| and |header|. * * @param {!MediaRouterContainerInterface} mediaRouterContainer * @param {!MediaRouterHeaderElement} mediaRouterHeader */ function setElements(mediaRouterContainer, mediaRouterHeader) { container = mediaRouterContainer; header = mediaRouterHeader; } /** * Populates the WebUI with data obtained about the first run flow. * * @param {{firstRunFlowCloudPrefLearnMoreUrl: string, * firstRunFlowLearnMoreUrl: string, * wasFirstRunFlowAcknowledged: boolean, * showFirstRunFlowCloudPref: boolean}} data * Parameters in data: * firstRunFlowCloudPrefLearnMoreUrl - url to open when the cloud services * pref learn more link is clicked. * firstRunFlowLearnMoreUrl - url to open when the first run flow learn * more link is clicked. * wasFirstRunFlowAcknowledged - true if first run flow was previously * acknowledged by user. * showFirstRunFlowCloudPref - true if the cloud pref option should be * shown. */ function setFirstRunFlowData(data) { container.firstRunFlowCloudPrefLearnMoreUrl = data['firstRunFlowCloudPrefLearnMoreUrl']; container.firstRunFlowLearnMoreUrl = data['firstRunFlowLearnMoreUrl']; container.showFirstRunFlowCloudPref = data['showFirstRunFlowCloudPref']; // Some users acknowledged the first run flow before the cloud prefs // setting was implemented. These users will see the first run flow // again. container.showFirstRunFlow = !data['wasFirstRunFlowAcknowledged'] || container.showFirstRunFlowCloudPref; } /** * Populates the WebUI with data obtained from Media Router. * * @param {{deviceMissingUrl: string, * sinksAndIdentity: { * sinks: !Array, * showEmail: boolean, * userEmail: string, * showDomain: boolean * }, * routes: !Array, * castModes: !Array, * useTabMirroring: boolean}} data * Parameters in data: * deviceMissingUrl - url to be opened on "Device missing?" clicked. * sinksAndIdentity - list of sinks to be displayed and user identity. * useWebUiRouteControls - whether new WebUI route controls should be used. * routes - list of routes that are associated with the sinks. * castModes - list of available cast modes. * useTabMirroring - whether the cast mode should be set to TAB_MIRROR. */ function setInitialData(data) { container.deviceMissingUrl = data['deviceMissingUrl']; container.castModeList = data['castModes']; this.setSinkListAndIdentity(data['sinksAndIdentity']); container.routeList = data['routes']; container.maybeShowRouteDetailsOnOpen(); if (data['useTabMirroring']) container.selectCastMode(media_router.CastModeType.TAB_MIRROR); media_router.browserApi.onInitialDataReceived(); } /** * Sets current issue to |issue|, or clears the current issue if |issue| is * null. * * @param {?media_router.Issue} issue */ function setIssue(issue) { container.issue = issue; } /** * Sets |routeControls|. The argument may be null if the route details view is * getting closed. * * @param {?RouteControlsInterface} mediaRouterRouteControls */ function setRouteControls(mediaRouterRouteControls) { routeControls = mediaRouterRouteControls; } /** * Sets the list of currently active routes. * * @param {!Array} routeList */ function setRouteList(routeList) { container.routeList = routeList; } /** * Sets the list of discovered sinks along with properties of whether to hide * identity of the user email and domain. * * @param {{sinks: !Array, * showEmail: boolean, * userEmail: string, * showDomain: boolean}} data * Parameters in data: * sinks - list of sinks to be displayed. * showEmail - true if the user email should be shown. * userEmail - email of the user if the user is signed in. * showDomain - true if the user domain should be shown. */ function setSinkListAndIdentity(data) { container.showDomain = data['showDomain']; container.allSinks = data['sinks']; header.userEmail = data['userEmail']; header.showEmail = data['showEmail']; } /** * Updates the max height of the dialog * * @param {number} height */ function updateMaxHeight(height) { container.updateMaxDialogHeight(height); } /** * Updates the route status shown in the route controls. * * @param {!media_router.RouteStatus} status */ function updateRouteStatus(status) { if (routeControls) { routeControls.routeStatus = status; } } function userSelectedLocalMediaFile(fileName) { container.onFileDialogSuccess(fileName); } return { onCreateRouteResponseReceived: onCreateRouteResponseReceived, onRouteControllerInvalidated: onRouteControllerInvalidated, receiveSearchResult: receiveSearchResult, setCastModeList: setCastModeList, setElements: setElements, setFirstRunFlowData: setFirstRunFlowData, setInitialData: setInitialData, setIssue: setIssue, setRouteControls: setRouteControls, setRouteList: setRouteList, setSinkListAndIdentity: setSinkListAndIdentity, updateMaxHeight: updateMaxHeight, updateRouteStatus: updateRouteStatus, userSelectedLocalMediaFile: userSelectedLocalMediaFile, }; }); // Handles user events for the Media Router UI. cr.define('media_router', function() { 'use strict'; /** * The media-router-container element. Initialized after polymer is ready. * @type {?MediaRouterContainerInterface} */ var container = null; /** * Initializes the Media Router WebUI and requests initial media * router content, such as the media sink and media route lists. */ function initialize() { // For non-Mac platforms, request data immediately after initialization. if (!cr.isMac) onRequestInitialData(); container = /** @type {!MediaRouterContainerInterface} */ ($('media-router-container')); media_router.ui.setElements(container, container.header); container.addEventListener( 'acknowledge-first-run-flow', onAcknowledgeFirstRunFlow); container.addEventListener('back-click', onNavigateToSinkList); container.addEventListener('cast-mode-selected', onCastModeSelected); container.addEventListener( 'change-route-source-click', onChangeRouteSourceClick); container.addEventListener('close-dialog', onCloseDialog); container.addEventListener('close-route', onCloseRoute); container.addEventListener('create-route', onCreateRoute); container.addEventListener('issue-action-click', onIssueActionClick); container.addEventListener('join-route-click', onJoinRouteClick); container.addEventListener( 'navigate-sink-list-to-details', onNavigateToDetails); container.addEventListener( 'navigate-to-cast-mode-list', onNavigateToCastMode); container.addEventListener( 'select-local-media-file', onSelectLocalMediaFile); container.addEventListener('report-filter', onFilter); container.addEventListener('report-initial-action', onInitialAction); container.addEventListener( 'report-initial-action-close', onInitialActionClose); container.addEventListener('report-route-creation', onReportRouteCreation); container.addEventListener( 'report-sink-click-time', onSinkClickTimeReported); container.addEventListener('report-sink-count', onSinkCountReported); container.addEventListener( 'report-resolved-route', onReportRouteCreationOutcome); container.addEventListener('request-initial-data', onRequestInitialData); container.addEventListener( 'search-sinks-and-create-route', onSearchSinksAndCreateRoute); container.addEventListener('show-initial-state', onShowInitialState); container.addEventListener('sink-click', onSinkClick); window.addEventListener('blur', onWindowBlur); } /** * Requests that the Media Router searches for a sink with criteria * |event.detail.name|. * @param {!Event} event * Parameters in |event|.detail: * id - id of the pseudo sink generating the request. * name - sink search criteria. * domain - user's current domain. * selectedCastMode - type of cast mode selected by the user. */ function onSearchSinksAndCreateRoute(event) { /** @type {{id: string, domain: string, name: string, * selectedCastMode: number}} */ var detail = event.detail; media_router.browserApi.searchSinksAndCreateRoute( detail.id, detail.name, detail.domain, detail.selectedCastMode); } /** * Reports the selected cast mode. * Called when the user selects a cast mode from the picker. * * @param {!Event} event * Parameters in |event|.detail: * castModeType - type of cast mode selected by the user. */ function onCastModeSelected(event) { /** @type {{castModeType: number}} */ var detail = event.detail; media_router.browserApi.reportSelectedCastMode(detail.castModeType); } /** * Reports the route for which the users wants to replace the source and the * cast mode that should be used for the new source. * * @param {!Event} event The event object. * Parameters in |event|.detail: * route - route to modify. * selectedCastMode - type of cast mode selected by the user. */ function onChangeRouteSourceClick(event) { /** @type {{route: !media_router.Route, selectedCastMode: number}} */ var detail = event.detail; media_router.browserApi.changeRouteSource( detail.route, detail.selectedCastMode); } /** * Sends a request to the browser to select a local file. */ function onSelectLocalMediaFile() { media_router.browserApi.selectLocalMediaFile(); } /** * Updates the preference that the user has seen the first run flow. * Called when the user clicks on the acknowledgement button on the first run * flow. * * @param {!Event} event * Parameters in |event|.detail: * optedIntoCloudServices - whether or not the user opted into cloud * services. */ function onAcknowledgeFirstRunFlow(event) { /** @type {{optedIntoCloudServices: boolean}} */ var detail = event.detail; media_router.browserApi.acknowledgeFirstRunFlow( detail.optedIntoCloudServices); } /** * Closes the dialog. * Called when the user clicks the close button on the dialog. Reports * whether the user closed the dialog via the ESC key. * * @param {!Event} event * Parameters in |event|.detail: * pressEscToClose - whether or not the user pressed ESC to close the * dialog. */ function onCloseDialog(event) { /** @type {{pressEscToClose: boolean}} */ var detail = event.detail; container.maybeReportUserFirstAction( media_router.MediaRouterUserAction.CLOSE); media_router.browserApi.closeDialog(detail.pressEscToClose); } /** * Reports when the user uses the filter input to filter the sink list. This * is reported at most once each time the user enters the filter view, and * only if text is actually entered in the filter input. */ function onFilter() { media_router.browserApi.reportFilter(); } /** * Reports the first action the user takes after opening the dialog. * Called when the user explicitly interacts with the dialog to perform an * action. * * @param {!Event} event * Parameters in |event|.detail: * action - the first action taken by the user. */ function onInitialAction(event) { /** @type {{action: number}} */ var detail = event.detail; media_router.browserApi.reportInitialAction(detail.action); } /** * Reports the time it took for the user to close the dialog if that was the * first action the user took after opening the dialog. * Called when the user closes the dialog without taking any other action. * * @param {!Event} event * Parameters in |event|.detail: * timeMs - time in ms for the user to close the dialog. */ function onInitialActionClose(event) { /** @type {{timeMs: number}} */ var detail = event.detail; media_router.browserApi.reportTimeToInitialActionClose(detail.timeMs); } /** * Acts on an issue and dismisses it from the UI. * Called when the user performs an action on an issue. * * @param {!Event} event * Parameters in |event|.detail: * id - issue ID. * actionType - type of action performed by the user. * helpPageId - the numeric help center ID. */ function onIssueActionClick(event) { /** @type {{id: number, actionType: number, helpPageId: number}} */ var detail = event.detail; media_router.browserApi.actOnIssue( detail.id, detail.actionType, detail.helpPageId); container.issue = null; } /** * Creates a media route. * Called when the user requests to create a media route. * * @param {!Event} event * Parameters in |event|.detail: * sinkId - sink ID selected by the user. * selectedCastModeValue - cast mode selected by the user. */ function onCreateRoute(event) { /** @type {{sinkId: string, selectedCastModeValue: number}} */ var detail = event.detail; media_router.browserApi.requestRoute( detail.sinkId, detail.selectedCastModeValue); } /** * Stops a route. * Called when the user requests to stop a media route. * * @param {!Event} event * Parameters in |event|.detail: * route - The route to close. */ function onCloseRoute(event) { /** @type {{route: !media_router.Route}} */ var detail = event.detail; media_router.browserApi.closeRoute(detail.route); } /** * Starts casting to an existing route. * Called when the user requests to start casting to a media route that is * joinable. * * @param {!Event} event * Parameters in |event|.detail: * route - The route to connect to if possible. */ function onJoinRouteClick(event) { /** @type {{route: !media_router.Route}} */ var detail = event.detail; media_router.browserApi.joinRoute(detail.route); } /** * Reports the user navigation to the cast mode view. * Called when the user clicks the drop arrow to navigate to the cast mode * view on the dialog. */ function onNavigateToCastMode() { media_router.browserApi.reportNavigateToView( media_router.MediaRouterView.CAST_MODE_LIST); } /** * Reports the user navigation the route details view. * Called when the user clicks on a sink to navigate to the route details * view. */ function onNavigateToDetails() { media_router.browserApi.reportNavigateToView( media_router.MediaRouterView.ROUTE_DETAILS); } /** * Reports the user navigation the sink list view. * Called when the user clicks on the back button from the route details view * to the sink list view. */ function onNavigateToSinkList() { media_router.browserApi.reportNavigateToView( media_router.MediaRouterView.SINK_LIST); } /** * Reports whether or not the route creation was successful. * * @param {!Event} event * Parameters in |event|.detail: * success - whether or not the route creation was successful. */ function onReportRouteCreation(event) { /** @type {{success: boolean}} */ var detail = event.detail; media_router.browserApi.reportRouteCreation(detail.success); } /** * Reports success or the type of failure for route creation response. * Called when the route is resolved; either the route creation was a success * or if there was no route or the route's corresponding sink is invalid; * either the sink does not exist or was not the sink we were looking for. * * @param {!Event} event * Parameters in |event|.detail: * outcome - the outcome of a create route response. * */ function onReportRouteCreationOutcome(event) { /** @type {{outcome: number}} */ var detail = event.detail; media_router.browserApi.reportRouteCreationOutcome(detail.outcome); } /** * Requests for initial data to load into the dialog. */ function onRequestInitialData() { media_router.browserApi.requestInitialData(); } /** * Reports the initial state of the dialog after it is opened. * Called after initial data is populated. * * @param {!Event} event * Parameters in |event|.detail: * currentView - the current dialog's current view. */ function onShowInitialState(event) { /** @type {{currentView: string}} */ var detail = event.detail; media_router.browserApi.reportInitialState(detail.currentView); } /** * Reports the index of the sink that was clicked. * Called when the user selects a sink on the sink list. * * @param {!Event} event * Paramters in |event|.detail: * index - the index of the clicked sink. */ function onSinkClick(event) { /** @type {{index: number}} */ var detail = event.detail; media_router.browserApi.reportClickedSinkIndex(detail.index); } /** * Reports the time it took for the user to select a sink to create a route * after the list was popuated and shown. * * @param {!Event} event * Paramters in |event|.detail: * timeMs - the time it took for the user to select a sink. */ function onSinkClickTimeReported(event) { /** @type {{timeMs: number}} */ var detail = event.detail; media_router.browserApi.reportTimeToClickSink(detail.timeMs); } /** * Reports the current sink count. * Called 3 seconds after the dialog is initially opened. * * @param {!Event} event * Parameters in |event|.detail: * sinkCount - the number of sinks. */ function onSinkCountReported(event) { /** @type {{sinkCount: number}} */ var detail = event.detail; media_router.browserApi.reportSinkCount(detail.sinkCount); } /** * Reports when the user clicks outside the dialog. */ function onWindowBlur() { media_router.browserApi.reportBlur(); } return { initialize: initialize, }; }); window.addEventListener('load', media_router.initialize); // 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. // API invoked by the browser MediaRouterWebUIMessageHandler to communicate // with this UI. cr.define('media_router.ui', function() { 'use strict'; // The media-router-container element. var container = null; // The media-router-header element. var header = null; // The route-controls element. Is null if the route details view isn't open. var routeControls = null; /** * Handles response of previous create route attempt. * * @param {string} sinkId The ID of the sink to which the Media Route was * creating a route. * @param {?media_router.Route} route The newly created route that * corresponds to the sink if route creation succeeded; null otherwise. * @param {boolean} isForDisplay Whether or not |route| is for display. */ function onCreateRouteResponseReceived(sinkId, route, isForDisplay) { container.onCreateRouteResponseReceived(sinkId, route, isForDisplay); } /** * Called when the route controller for the route that is currently selected * is invalidated. */ function onRouteControllerInvalidated() { container.onRouteControllerInvalidated(); } /** * Handles the search response by forwarding |sinkId| to the container. * * @param {string} sinkId The ID of the sink found by search. */ function receiveSearchResult(sinkId) { container.onReceiveSearchResult(sinkId); } /** * Sets the cast mode list. * * @param {!Array} castModeList */ function setCastModeList(castModeList) { container.castModeList = castModeList; } /** * Sets |container| and |header|. * * @param {!MediaRouterContainerInterface} mediaRouterContainer * @param {!MediaRouterHeaderElement} mediaRouterHeader */ function setElements(mediaRouterContainer, mediaRouterHeader) { container = mediaRouterContainer; header = mediaRouterHeader; } /** * Populates the WebUI with data obtained about the first run flow. * * @param {{firstRunFlowCloudPrefLearnMoreUrl: string, * firstRunFlowLearnMoreUrl: string, * wasFirstRunFlowAcknowledged: boolean, * showFirstRunFlowCloudPref: boolean}} data * Parameters in data: * firstRunFlowCloudPrefLearnMoreUrl - url to open when the cloud services * pref learn more link is clicked. * firstRunFlowLearnMoreUrl - url to open when the first run flow learn * more link is clicked. * wasFirstRunFlowAcknowledged - true if first run flow was previously * acknowledged by user. * showFirstRunFlowCloudPref - true if the cloud pref option should be * shown. */ function setFirstRunFlowData(data) { container.firstRunFlowCloudPrefLearnMoreUrl = data['firstRunFlowCloudPrefLearnMoreUrl']; container.firstRunFlowLearnMoreUrl = data['firstRunFlowLearnMoreUrl']; container.showFirstRunFlowCloudPref = data['showFirstRunFlowCloudPref']; // Some users acknowledged the first run flow before the cloud prefs // setting was implemented. These users will see the first run flow // again. container.showFirstRunFlow = !data['wasFirstRunFlowAcknowledged'] || container.showFirstRunFlowCloudPref; } /** * Populates the WebUI with data obtained from Media Router. * * @param {{deviceMissingUrl: string, * sinksAndIdentity: { * sinks: !Array, * showEmail: boolean, * userEmail: string, * showDomain: boolean * }, * routes: !Array, * castModes: !Array, * useTabMirroring: boolean}} data * Parameters in data: * deviceMissingUrl - url to be opened on "Device missing?" clicked. * sinksAndIdentity - list of sinks to be displayed and user identity. * useWebUiRouteControls - whether new WebUI route controls should be used. * routes - list of routes that are associated with the sinks. * castModes - list of available cast modes. * useTabMirroring - whether the cast mode should be set to TAB_MIRROR. */ function setInitialData(data) { container.deviceMissingUrl = data['deviceMissingUrl']; container.castModeList = data['castModes']; this.setSinkListAndIdentity(data['sinksAndIdentity']); container.routeList = data['routes']; container.maybeShowRouteDetailsOnOpen(); if (data['useTabMirroring']) container.selectCastMode(media_router.CastModeType.TAB_MIRROR); media_router.browserApi.onInitialDataReceived(); } /** * Sets current issue to |issue|, or clears the current issue if |issue| is * null. * * @param {?media_router.Issue} issue */ function setIssue(issue) { container.issue = issue; } /** * Sets |routeControls|. The argument may be null if the route details view is * getting closed. * * @param {?RouteControlsInterface} mediaRouterRouteControls */ function setRouteControls(mediaRouterRouteControls) { routeControls = mediaRouterRouteControls; } /** * Sets the list of currently active routes. * * @param {!Array} routeList */ function setRouteList(routeList) { container.routeList = routeList; } /** * Sets the list of discovered sinks along with properties of whether to hide * identity of the user email and domain. * * @param {{sinks: !Array, * showEmail: boolean, * userEmail: string, * showDomain: boolean}} data * Parameters in data: * sinks - list of sinks to be displayed. * showEmail - true if the user email should be shown. * userEmail - email of the user if the user is signed in. * showDomain - true if the user domain should be shown. */ function setSinkListAndIdentity(data) { container.showDomain = data['showDomain']; container.allSinks = data['sinks']; header.userEmail = data['userEmail']; header.showEmail = data['showEmail']; } /** * Updates the max height of the dialog * * @param {number} height */ function updateMaxHeight(height) { container.updateMaxDialogHeight(height); } /** * Updates the route status shown in the route controls. * * @param {!media_router.RouteStatus} status */ function updateRouteStatus(status) { if (routeControls) { routeControls.routeStatus = status; } } function userSelectedLocalMediaFile(fileName) { container.onFileDialogSuccess(fileName); } return { onCreateRouteResponseReceived: onCreateRouteResponseReceived, onRouteControllerInvalidated: onRouteControllerInvalidated, receiveSearchResult: receiveSearchResult, setCastModeList: setCastModeList, setElements: setElements, setFirstRunFlowData: setFirstRunFlowData, setInitialData: setInitialData, setIssue: setIssue, setRouteControls: setRouteControls, setRouteList: setRouteList, setSinkListAndIdentity: setSinkListAndIdentity, updateMaxHeight: updateMaxHeight, updateRouteStatus: updateRouteStatus, userSelectedLocalMediaFile: userSelectedLocalMediaFile, }; }); // 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 Polymer element is used to show information about issues related // to casting. Polymer({ is: 'issue-banner', properties: { /** * Maps an issue action type to the resource identifier of the text shown * in the action button. * This is a property of issue-banner because it is used in tests. This * property should always be set before |issue| is set or updated. * @private {!Array} */ actionTypeToButtonTextResource_: { type: Array, readOnly: true, value: function() { return ['dismissButton', 'learnMoreText']; }, }, /** * The text shown in the default action button. * @private {string|undefined} */ defaultActionButtonText_: { type: String, }, /** * The issue to show. * @type {?media_router.Issue|undefined} */ issue: { type: Object, observer: 'updateActionButtonText_', }, /** * The text shown in the secondary action button. * @private {string|undefined} */ secondaryActionButtonText_: { type: String, }, }, behaviors: [ I18nBehavior, ], /** * @param {?media_router.Issue} issue * @return {boolean} Whether or not to hide the blocking issue UI. * @private */ computeIsBlockingIssueHidden_: function(issue) { return !issue || !issue.isBlocking; }, /** * @param {?media_router.Issue} issue The current issue. * @return {string} The class for the overall issue-banner. * @private */ computeIssueClass_: function(issue) { if (!issue) return ''; return issue.isBlocking ? 'blocking' : 'non-blocking'; }, /** * @param {?media_router.Issue} issue * @return {boolean} Whether or not to hide the non-blocking issue UI. * @private */ computeOptionalActionHidden_: function(issue) { return !issue || issue.secondaryActionType === undefined; }, /** * Fires an issue-action-click event. * * @param {number} actionType The type of issue action. * @private */ fireIssueActionClick_: function(actionType) { this.fire('issue-action-click', { id: this.issue.id, actionType: actionType, helpPageId: this.issue.helpPageId }); }, /** * Called when a default issue action is clicked. * * @param {!Event} event The event object. * @private */ onClickDefaultAction_: function(event) { this.fireIssueActionClick_(this.issue.defaultActionType); }, /** * Called when an optional issue action is clicked. * * @param {!Event} event The event object. * @private */ onClickOptAction_: function(event) { this.fireIssueActionClick_( /** @type {number} */ (this.issue.secondaryActionType)); }, /** * Called when |issue| is updated. This updates the default and secondary * action button text. * * @private */ updateActionButtonText_: function() { var defaultText = ''; var secondaryText = ''; if (this.issue) { defaultText = this.i18n( this.actionTypeToButtonTextResource_[this.issue.defaultActionType]); if (this.issue.secondaryActionType !== undefined) { secondaryText = this.i18n( this.actionTypeToButtonTextResource_[this.issue .secondaryActionType]); } } this.defaultActionButtonText_ = defaultText; this.secondaryActionButtonText_ = secondaryText; }, }); /* 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. */ .blocking { background-color: white; overflow: hidden; position: relative; text-align: center; } .blocking > #buttons { padding-bottom: 24px; padding-top: 20px; } .blocking > div > #title { color: rgba(0, 0, 0, 0.87); line-height: 1.125em; padding: 10px; vertical-align: middle; } #blocking-icon { color: var(--google-red-500); height: 75px; padding-top: 24px; width: 75px; } .non-blocking { background-color: var(--paper-grey-800); padding: 16px; width: inherit; } .non-blocking > #buttons { display: flex; flex-direction: row; justify-content: flex-end; width: 100%; } .non-blocking > #buttons > .button { color: var(--paper-blue-300); } .non-blocking > #buttons > #default-button { -webkit-margin-end: 24px; } .non-blocking > div > #title { -webkit-margin-end: 12px; -webkit-padding-end: 12px; color: rgba(255, 255, 255, 0.87); overflow: hidden; } paper-button { margin: 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. */ .active-sink { color: var(--paper-blue-700); } .cast-mode-icon, .sink-icon { -webkit-padding-end: 12px; -webkit-padding-start: var(--dialog-padding-start); height: var(--non-navigation-icon-size); width: var(--non-navigation-icon-size); } #cast-mode-list { padding-bottom: 12px; padding-top: 4px; } #container-header { position: fixed; width: 100%; } #content { position: relative; } #device-missing { align-items: center; background-color: white; display: flex; justify-content: center; padding: 60px 0; } #device-missing a { color: var(--paper-blue-700); margin: 8px 0; text-align: center; text-decoration: none; } #first-run-button { background-color: white; } #first-run-button-container { display: flex; flex-direction: row; justify-content: flex-end; } #first-run-cloud-checkbox, #first-run-flow-cloud-pref, #first-run-text { font-size: 1.0em; line-height: 1.5em; } #first-run-cloud-checkbox, #first-run-text, #first-run-title { color: white; padding-bottom: 24px; } #first-run-cloud-checkbox::shadow #checkboxLabel { -webkit-padding-start: var(-dialog-padding-start); } #first-run-flow { background-color: var(--paper-blue-700); box-sizing: border-box; padding: 24px 16px 4px 16px; position: fixed; width: 100%; } #first-run-flow a { color: white; text-decoration: none; } #first-run-flow-cloud-pref { color: white; display: flex; } .first-run-learn-more { font-weight: bold; text-transform: uppercase; } #first-run-title { font-size: 1.25em; } #issue-banner { width: 100%; } #issue-banner.non-blocking { bottom: 0; display: block; margin-top: 0; } #no-search-matches { color: rgb(112, 112, 112); display: block; font-size: 1.2 em; padding-bottom: 20px; padding-top: 20px; text-align: center; } paper-checkbox { --paper-checkbox-checked-color: white; --paper-checkbox-checkmark-color: var(--paper-blue-700); --paper-checkbox-ink-size: 35px; --paper-checkbox-unchecked-color: white; } paper-item { cursor: pointer; font-size: 1.0em; line-height: 0; min-height: 0; padding: 12px 0; } paper-item:hover { background-color: rgb(238, 238, 238); border: 0; } paper-menu { color: rgba(0, 0, 0, 0.87); overflow-x: hidden; overflow-y: auto; padding-bottom: 0; padding-top: 4px; user-select: none; } #search-input-container { flex-grow: 1; } #search-results { overflow-x: hidden; overflow-y: auto; } #search-results-container { bottom: 0; left: 0; overflow-x: hidden; overflow-y: hidden; position: absolute; right: 0; top: 100%; } #searching-devices-spinner { height: 30px; width: 30px; } .subheading-text { -webkit-padding-start: var(--dialog-padding-start); color: var(--paper-grey-600); cursor: default; font-weight: normal; padding-bottom: 4px; padding-top: 12px; } #share-screen-text::after { background-color: white; font-weight: normal; } .sink-content { display: flex; flex-direction: row; font-weight: normal; } .sink-domain { -webkit-padding-start: 6px; color: var(--paper-grey-600); /* TODO(crbug/589697): Handle overflow of very long domain names. */ } #sink-list { overflow-x: hidden; overflow-y: auto; } #sink-list-view { margin-bottom: 12px; position: relative; } .sink-name { min-width: 10%; } #sink-search { padding-bottom: 0; padding-top: 4px; position: absolute; top: 100%; width: 100%; z-index: 1; } /* Separate icon class is a consequence of box-sizing: border-box set by * paper-icon-button. This should achieve the same dimensions as .sink-icon. */ #sink-search-icon { -webkit-margin-start: 4px; -webkit-padding-end: 12px; -webkit-padding-start: 12px; } #sink-search-input { --paper-input-container: { margin: 8px 0; padding: 0; }; --paper-input-container-focus-color: rgb(33, 150, 243); --paper-input-container-input: { font-size: 12px; }; --paper-input-container-label: { font-size: 12px; }; -webkit-margin-end: 31px; box-sizing: border-box; } .sink-subtext { color: var(--paper-grey-600); padding-top: 8px; } .sink-text { flex-flow: row nowrap; line-height: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 275px; } // 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 Polymer element contains the entire media router interface. It handles * hiding and showing specific components. * @implements {MediaRouterContainerInterface} */ Polymer({ is: 'media-router-container', properties: { /** * The list of available sinks. * @type {!Array} */ allSinks: { type: Array, value: [], observer: 'reindexSinksAndRebuildSinksToShow_', }, /** * The last promise in a chain that will be fulfilled when the current * animation has finished. It does not return a value; it is strictly a * synchronization mechanism. * @private {!Promise} */ animationPromise_: { type: Object, value: function() { return Promise.resolve(); }, }, /** * The list of CastModes to show. * @type {!Array|undefined} */ castModeList: { type: Array, observer: 'checkCurrentCastMode_', }, /** * The ID of the Sink currently being launched. * @private {string} * TODO(crbug.com/616604): Use per-sink route creation state. */ currentLaunchingSinkId_: { type: String, value: '', }, /** * The current route. * @private {?media_router.Route|undefined} */ currentRoute_: { type: Object, }, /** * The current view to be shown. * @private {?media_router.MediaRouterView|undefined} */ currentView_: { type: String, observer: 'currentViewChanged_', }, /** * The URL to open when the device missing link is clicked. * @type {string|undefined} */ deviceMissingUrl: { type: String, }, /** * The height of the dialog. * @private {number} */ dialogHeight_: { type: Number, value: 330, }, /** * The time |this| element calls ready(). * @private {number|undefined} */ elementReadyTimeMs_: { type: Number, }, /** * Animation player used for running filter transition animations. * @private {?Animation} */ filterTransitionPlayer_: { type: Object, value: null, }, /** * The URL to open when the cloud services pref learn more link is clicked. * @type {string|undefined} */ firstRunFlowCloudPrefLearnMoreUrl: { type: String, }, /** * The URL to open when the first run flow learn more link is clicked. * @type {string|undefined} */ firstRunFlowLearnMoreUrl: { type: String, }, /** * The header text for the sink list. * @type {string|undefined} */ headerText: { type: String, }, /** * The header text tooltip. This would be descriptive of the * source origin, whether a host name, tab URL, etc. * @type {string|undefined} */ headerTextTooltip: { type: String, }, /** * An animation player that is used for running dialog height adjustments. * @private {?Animation} */ heightAdjustmentPlayer_: { type: Object, value: null, }, /** * Whether the sink list is being hidden for animation purposes. * @private {boolean} */ hideSinkListForAnimation_: { type: Boolean, value: false, }, /** * Records whether the search input is focused when a window blur event is * received. This is used to handle search focus edge cases. See * |setSearchFocusHandlers_| for details. * @private {boolean} */ isSearchFocusedOnWindowBlur_: { type: Boolean, value: false, }, /** * Whether the search list is currently hidden. * @private {boolean} */ isSearchListHidden_: { type: Boolean, value: true, }, /** * The issue to show. * @type {?media_router.Issue} */ issue: { type: Object, value: null, observer: 'maybeShowIssueView_', }, /** * Whether the MR UI was just opened. * @private {boolean} */ justOpened_: { type: Boolean, value: true, }, /** * Whether the user's mouse is positioned over the dialog. * @private {boolean|undefined} */ mouseIsPositionedOverDialog_: { type: Boolean, }, /** * The ID of the route that is currently being created. This is set when * route creation is resolved but not ready for its controls to be * displayed. * @private {string|undefined} */ pendingCreatedRouteId_: { type: String, }, /** * The time the sink list was shown and populated with at least one sink. * This is reset whenever the user switches views or there are no sinks * available for display. * @private {number} */ populatedSinkListSeenTimeMs_: { type: Number, value: -1, }, /** * Pseudo sinks from MRPs that represent their ability to accept sink search * requests. * @private {!Array} */ pseudoSinks_: { type: Array, value: [], }, /** * Helps manage the state of creating a sink and a route from a pseudo sink. * @private {PseudoSinkSearchState|undefined} */ pseudoSinkSearchState_: { type: Object, }, /** * Whether the next character input should cause a filter action metric to * be sent. * @type {boolean} * @private */ reportFilterOnInput_: { type: Boolean, value: false, }, /** * The list of current routes. * @type {!Array|undefined} */ routeList: { type: Array, observer: 'rebuildRouteMaps_', }, /** * Maps media_router.Route.id to corresponding media_router.Route. * @private {!Object|undefined} */ routeMap_: { type: Object, }, /** * Whether the search feature is enabled and we should show the search * input. * @private {boolean} */ searchEnabled_: { type: Boolean, value: false, observer: 'searchEnabledChanged_', }, /** * Search text entered by the user into the sink search input. * @private {string} */ searchInputText_: { type: String, value: '', observer: 'searchInputTextChanged_', }, /** * Sinks to display that match |searchInputText_|. * @private {!Array>}>|undefined} */ searchResultsToShow_: { type: Array, }, /** * The selected cast mode menu item. The item with this index is bolded in * the cast mode menu. * @private {number|undefined} */ selectedCastModeMenuItem_: { type: Number, observer: 'updateSelectedCastModeMenuItem_', }, /** * Whether to show the user domain of sinks associated with identity. * @type {boolean|undefined} */ showDomain: { type: Boolean, }, /** * Whether to show the first run flow. * @type {boolean|undefined} */ showFirstRunFlow: { type: Boolean, observer: 'updateElementPositioning_', }, /** * Whether to show the cloud preference setting in the first run flow. * @type {boolean|undefined} */ showFirstRunFlowCloudPref: { type: Boolean, }, /** * The cast mode shown to the user. Initially set to auto mode. (See * media_router.CastMode documentation for details on auto mode.) * This value may be changed in one of the following ways: * 1) The user explicitly selected a cast mode. * 2) The user selected cast mode is no longer available for the associated * WebContents. In this case, the container will reset to auto mode. Note * that |userHasSelectedCastMode_| will switch back to false. * 3) The sink list changed, and the user had not explicitly selected a cast * mode. If the sinks support exactly 1 cast mode, the container will * switch to that cast mode. Otherwise, the container will reset to auto * mode. * @private {number} */ shownCastModeValue_: { type: Number, value: media_router.AUTO_CAST_MODE.type, }, /** * Max height for the sink list. * @private {number} */ sinkListMaxHeight_: { type: Number, value: 0, }, /** * Maps media_router.Sink.id to corresponding media_router.Sink. * @private {!Object|undefined} */ sinkMap_: { type: Object, }, /** * Maps media_router.Sink.id to corresponding media_router.Route. * @private {!Object} */ sinkToRouteMap_: { type: Object, value: {}, }, /** * Sinks to show for the currently selected cast mode. * @private {!Array|undefined} */ sinksToShow_: { type: Array, observer: 'updateElementPositioning_', }, /** * Whether the user has explicitly selected a cast mode. * @private {boolean} */ userHasSelectedCastMode_: { type: Boolean, value: false, }, /** * Whether the user has already taken an action. * @type {boolean} */ userHasTakenInitialAction_: { type: Boolean, value: false, }, }, behaviors: [ I18nBehavior, ], observers: [ 'maybeUpdateStartSinkDisplayStartTime_(currentView_, sinksToShow_)', ], ready: function() { this.elementReadyTimeMs_ = window.performance.now(); this.showSinkList_(); Polymer.RenderStatus.afterNextRender(this, function() { // Import the elements that aren't needed at startup. This reduces // initial load time. Delayed loading interferes with getting the // offsetHeight of the first-run-flow element in updateElementPositioning_ // though, so we also make sure it is called after the last load. var that = this; var loadsRemaining = 3; var onload = function() { loadsRemaining--; if (loadsRemaining > 0) { return; } that.updateElementPositioning_(); if (that.currentView_ == media_router.MediaRouterView.SINK_LIST) { that.putSearchAtBottom_(); } }; this.importHref( 'chrome://resources/polymer/v1_0/neon-animation/' + 'web-animations.html', onload); this.importHref( this.resolveUrl('../issue_banner/issue_banner.html'), onload); this.importHref( this.resolveUrl( '../media_router_search_highlighter/' + 'media_router_search_highlighter.html'), onload); // If this is not on a Mac platform, remove the placeholder. See // onFocus_() for more details. ready() is only called once, so no need // to check if the placeholder exist before removing. if (!cr.isMac) this.$$('#focus-placeholder').remove(); document.addEventListener('keydown', this.onKeydown_.bind(this), true); this.listen(this, 'focus', 'onFocus_'); this.listen(this, 'header-height-changed', 'updateElementPositioning_'); this.listen(this, 'header-or-arrow-click', 'toggleCastModeHidden_'); this.listen(this, 'mouseleave', 'onMouseLeave_'); this.listen(this, 'mouseenter', 'onMouseEnter_'); // Turn off the spinner after 3 seconds, then report the current number of // sinks. this.async(function() { this.justOpened_ = false; this.fire('report-sink-count', { sinkCount: this.allSinks.length, }); }, 3000 /* 3 seconds */); // For Mac platforms, request data after a short delay after load. This // appears to speed up initial data load time on Mac. if (cr.isMac) { this.async(function() { this.fire('request-initial-data'); }, 25 /* 0.025 seconds */); } }); }, /** * Fires an acknowledge-first-run-flow event and hides the first run flow. * This is call when the first run flow button is clicked. * * @private */ acknowledgeFirstRunFlow_: function() { // Only set |userOptedIntoCloudServices| if the user was shown the cloud // services preferences option. var userOptedIntoCloudServices = this.showFirstRunFlowCloudPref ? this.$$('#first-run-cloud-checkbox').checked : undefined; this.fire('acknowledge-first-run-flow', { optedIntoCloudServices: userOptedIntoCloudServices, }); this.showFirstRunFlow = false; this.showFirstRunFlowCloudPref = false; }, /** * Fires a 'report-initial-action' event when the user takes their first * action after the dialog opens. Also fires a 'report-initial-action-close' * event if that initial action is to close the dialog. * @param {!media_router.MediaRouterUserAction} initialAction */ maybeReportUserFirstAction: function(initialAction) { if (this.userHasTakenInitialAction_) return; this.fire('report-initial-action', { action: initialAction, }); if (initialAction == media_router.MediaRouterUserAction.CLOSE) { var timeToClose = window.performance.now() - this.elementReadyTimeMs_; this.fire('report-initial-action-close', { timeMs: timeToClose, }); } this.userHasTakenInitialAction_ = true; }, get header() { return this.$['container-header']; }, /** * Calls all the functions to set the UI to a given cast mode. * @param {!media_router.CastMode} castMode The cast mode to set things to. * @private */ castModeSelected_(castMode) { this.selectCastMode(castMode.type); this.fire('cast-mode-selected', {castModeType: castMode.type}); this.showSinkList_(); this.maybeReportUserFirstAction( media_router.MediaRouterUserAction.CHANGE_MODE); }, /** * Checks that the currently selected cast mode is still in the * updated list of available cast modes. If not, then update the selected * cast mode to the first available cast mode on the list. */ checkCurrentCastMode_: function() { if (!this.castModeList.length) return; // If there is a forced mode make sure it is shown. if (this.findForcedCastMode_()) { this.rebuildSinksToShow_(); } // If we are currently showing auto mode, then nothing needs to be done. // Otherwise, if the cast mode currently shown no longer exists (regardless // of whether it was selected by user), then switch back to auto cast mode. if (this.shownCastModeValue_ != media_router.CastModeType.AUTO && !this.findCastModeByType_(this.shownCastModeValue_)) { this.setShownCastMode_(media_router.AUTO_CAST_MODE); this.rebuildSinksToShow_(); } }, /** * Compares two search match objects for sorting. Earlier and longer matches * are prioritized. * * @param {!{sinkItem: !media_router.Sink, * substrings: Array>}} resultA * Parameters in |resultA|: * sinkItem - sink object. * substrings - start-end index pairs of substring matches. * @param {!{sinkItem: !media_router.Sink, * substrings: Array>}} resultB * Parameters in |resultB|: * sinkItem - sink object. * substrings - start-end index pairs of substring matches. * @return {number} -1 if |resultA| should come before |resultB|, 1 if * |resultB| should come before |resultA|, and 0 if they are considered * equal. */ compareSearchMatches_: function(resultA, resultB) { var substringsA = resultA.substrings; var substringsB = resultB.substrings; var numberSubstringsA = substringsA.length; var numberSubstringsB = substringsB.length; if (numberSubstringsA == 0 && numberSubstringsB == 0) { return 0; } else if (numberSubstringsA == 0) { return 1; } else if (numberSubstringsB == 0) { return -1; } var loopMax = Math.min(numberSubstringsA, numberSubstringsB); for (var i = 0; i < loopMax; ++i) { var [matchStartA, matchEndA] = substringsA[i]; var [matchStartB, matchEndB] = substringsB[i]; if (matchStartA < matchStartB) { return -1; } else if (matchStartA > matchStartB) { return 1; } if (matchEndA > matchEndB) { return -1; } else if (matchEndA < matchEndB) { return 1; } } if (numberSubstringsA > numberSubstringsB) { return -1; } else if (numberSubstringsA < numberSubstringsB) { return 1; } return 0; }, /** * Returns a duration in ms from a distance in pixels using a default speed of * 1000 pixels per second. * @param {number} distance Number of pixels that will be traveled. * @private */ computeAnimationDuration_: function(distance) { // The duration of the animation can be found by abs(distance)/speed, where // speed is fixed at 1000 pixels per second, or 1 pixel per millisecond. return Math.abs(distance); }, /** * If there is a forced cast mode, returns that cast mode. If |allSinks| * supports only a single cast mode, returns that cast mode. Otherwise, * returns AUTO_MODE. Only called if |userHasSelectedCastMode_| is |false|. * * @return {!media_router.CastMode} The single cast mode supported by * |allSinks|, or AUTO_MODE. */ computeCastMode_: function() { /** @const */ var forcedMode = this.findForcedCastMode_(); if (forcedMode) return forcedMode; var allCastModes = this.allSinks.reduce(function(castModesSoFar, sink) { // Ignore pseudo sinks in the cast mode computation. return castModesSoFar | (sink.isPseudoSink ? 0 : sink.castModes); }, 0); // This checks whether |castModes| does not consist of exactly 1 cast mode. if (!allCastModes || allCastModes & (allCastModes - 1)) return media_router.AUTO_CAST_MODE; var castMode = this.findCastModeByType_(allCastModes); if (castMode) return castMode; console.error('Cast mode ' + allCastModes + ' not in castModeList'); return media_router.AUTO_CAST_MODE; }, /** * @param {?media_router.MediaRouterView} view The current view. * @return {boolean} Whether or not to hide the cast mode list. * @private */ computeCastModeListHidden_: function(view) { return view != media_router.MediaRouterView.CAST_MODE_LIST; }, /** * @param {!media_router.CastMode} castMode The cast mode to determine an * icon for. * @return {string} The icon to use. * @private */ computeCastModeIcon_: function(castMode) { switch (castMode.type) { case media_router.CastModeType.PRESENTATION: return 'media-router:web'; case media_router.CastModeType.TAB_MIRROR: return 'media-router:tab'; case media_router.CastModeType.DESKTOP_MIRROR: return 'media-router:laptop'; case media_router.CastModeType.LOCAL_FILE: return 'media-router:folder'; default: return ''; } }, /** * @param {!Array} castModeList The current list of * cast modes. * @return {!Array} The list of PRESENTATION cast * modes. * @private */ computePresentationCastModeList_: function(castModeList) { return castModeList.filter(function(mode) { return mode.type == media_router.CastModeType.PRESENTATION; }); }, /** * @param {!Array} sinksToShow The list of sinks. * @return {boolean} Whether or not to hide the 'devices missing' message. * @private */ computeDeviceMissingHidden_: function(sinksToShow) { return sinksToShow.length != 0; }, /** * @param {?Element} element Element to compute padding for. * @return {number} Computes the amount of vertical padding (top + bottom) on * |element|. * @private */ computeElementVerticalPadding_: function(element) { var paddingBottom, paddingTop; [paddingBottom, paddingTop] = this.getElementVerticalPadding_(element); return paddingBottom + paddingTop; }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {?media_router.Issue} issue The current issue. * @return {boolean} Whether or not to hide the header. * @private */ computeHeaderHidden_: function(view, issue) { return view == media_router.MediaRouterView.ROUTE_DETAILS || (view == media_router.MediaRouterView.SINK_LIST && !!issue && issue.isBlocking); }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {string} headerText The header text for the sink list. * @return {string|undefined} The text for the header. * @private */ computeHeaderText_: function(view, headerText) { switch (view) { case media_router.MediaRouterView.CAST_MODE_LIST: return this.i18n('selectCastModeHeaderText'); case media_router.MediaRouterView.ISSUE: return this.i18n('issueHeaderText'); case media_router.MediaRouterView.ROUTE_DETAILS: return this.currentRoute_ && this.sinkMap_[this.currentRoute_.sinkId] ? this.sinkMap_[this.currentRoute_.sinkId].name : ''; case media_router.MediaRouterView.SINK_LIST: case media_router.MediaRouterView.FILTER: return this.headerText; default: return ''; } }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {string} headerTooltip The tooltip for the header for the sink * list. * @return {string} The tooltip for the header. * @private */ computeHeaderTooltip_: function(view, headerTooltip) { return view == media_router.MediaRouterView.SINK_LIST ? headerTooltip : ''; }, /** * @param {string} currentLaunchingSinkId ID of the sink that is currently * launching, or empty string if none exists. * @private */ computeIsLaunching_: function(currentLaunchingSinkId) { return currentLaunchingSinkId != ''; }, /** * @param {?media_router.Issue} issue The current issue. * @return {string} The class for the issue banner. * @private */ computeIssueBannerClass_: function(issue) { return !!issue && !issue.isBlocking ? 'non-blocking' : ''; }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {?media_router.Issue} issue The current issue. * @return {boolean} Whether or not to show the issue banner. * @private */ computeIssueBannerShown_: function(view, issue) { return !!issue && (view == media_router.MediaRouterView.CAST_MODE_LIST || view == media_router.MediaRouterView.SINK_LIST || view == media_router.MediaRouterView.FILTER || view == media_router.MediaRouterView.ISSUE); }, /** * @param {!Array>}>} searchResultsToShow * The sinks currently matching the search text. * @param {boolean} isSearchListHidden Whether the search list is hidden. * @return {boolean} Whether or not the 'no matches' message is hidden. * @private */ computeNoMatchesHidden_: function(searchResultsToShow, isSearchListHidden) { return isSearchListHidden || this.searchInputText_.length == 0 || searchResultsToShow.length != 0; }, /** * @param {!Array} castModeList The current list of * cast modes. * @return {!Array} The list of non-PRESENTATION cast * modes. Also excludes LOCAL_FILE. * @private */ computeShareScreenCastModeList_: function(castModeList) { return castModeList.filter(function(mode) { return mode.type == media_router.CastModeType.DESKTOP_MIRROR || mode.type == media_router.CastModeType.TAB_MIRROR; }); }, /** * @param {!Array} castModeList The current list of * cast modes. * @return {!Array} The list of local media cast * modes. * @private */ computeLocalMediaCastModeList_: function(castModeList) { return castModeList.filter(function(mode) { return mode.type == media_router.CastModeType.LOCAL_FILE; }); }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {?media_router.Issue} issue The current issue. * @return {boolean} Whether or not to hide the route details. * @private */ computeRouteDetailsHidden_: function(view, issue) { return view != media_router.MediaRouterView.ROUTE_DETAILS || (!!issue && issue.isBlocking); }, /** * Computes an array of substring indices that mark where substrings of * |searchString| occur in |sinkName|. * * @param {string} searchString Search string entered by user. * @param {string} sinkName Sink name being filtered. * @return {Array>} Array of substring start-end (inclusive) * index pairs if every character in |searchString| was matched, in order, * in |sinkName|. Otherwise it returns null. * @private */ computeSearchMatches_: function(searchString, sinkName) { var i = 0; var matchStart = -1; var matchEnd = -1; var matchPairs = []; for (var j = 0; i < searchString.length && j < sinkName.length; ++j) { if (searchString[i].toLocaleLowerCase() == sinkName[j].toLocaleLowerCase()) { if (matchStart == -1) { matchStart = j; } ++i; } else if (matchStart != -1) { matchEnd = j - 1; matchPairs.push([matchStart, matchEnd]); matchStart = -1; } } if (matchStart != -1) { matchEnd = j - 1; matchPairs.push([matchStart, matchEnd]); } return (i == searchString.length) ? matchPairs : null; }, /** * Computes whether the search results list should be hidden. * @param {!Array>}>} searchResultsToShow * The sinks currently matching the search text. * @param {boolean} isSearchListHidden Whether the search list is hidden. * @return {boolean} Whether the search results list should be hidden. * @private */ computeSearchResultsHidden_: function( searchResultsToShow, isSearchListHidden) { return isSearchListHidden || searchResultsToShow.length == 0; }, /** * @param {!Array} castModeList The current list of * cast modes. * @return {boolean} Whether or not to hide the share screen subheading text. * @private */ computeShareScreenSubheadingHidden_: function(castModeList) { return this.computeShareScreenCastModeList_(castModeList).length == 0; }, /** * @param {!Array} castModeList The current list of * cast modes. * @return {boolean} Whether or not to hide the local media subheading text. * @private */ computeLocalMediaSubheadingHidden_: function(castModeList) { return this.computeLocalMediaCastModeList_(castModeList).length == 0; }, /** * @param {boolean} showFirstRunFlow Whether or not to show the first run * flow. * @param {?media_router.MediaRouterView} currentView The current view. * @private */ computeShowFirstRunFlow_: function(showFirstRunFlow, currentView) { return showFirstRunFlow && currentView == media_router.MediaRouterView.SINK_LIST; }, /** * @param {!media_router.Sink} sink The sink to determine an icon for. * @return {string} The icon to use. * @private */ computeSinkIcon_: function(sink) { switch (sink.iconType) { case media_router.SinkIconType.CAST: return 'media-router:chromecast'; case media_router.SinkIconType.CAST_AUDIO_GROUP: return 'media-router:speaker-group'; case media_router.SinkIconType.CAST_AUDIO: return 'media-router:speaker'; case media_router.SinkIconType.MEETING: return 'media-router:meeting'; case media_router.SinkIconType.HANGOUT: return 'media-router:hangout'; case media_router.SinkIconType.EDUCATION: return 'media-router:education'; case media_router.SinkIconType.WIRED_DISPLAY: return 'media-router:tv'; case media_router.SinkIconType.GENERIC: return 'media-router:tv'; default: return 'media-router:tv'; } }, /** * @param {!string} sinkId A sink ID. * @param {!Object} sinkToRouteMap * Maps media_router.Sink.id to corresponding media_router.Route. * @return {string} The class for the sink icon. * @private */ computeSinkIconClass_: function(sinkId, sinkToRouteMap) { return sinkToRouteMap[sinkId] ? 'sink-icon active-sink' : 'sink-icon'; }, /** * @param {!string} currentLaunchingSinkId The ID of the sink that is * currently launching. * @param {!string} sinkId A sink ID. * @return {boolean} |true| if given sink is currently launching. * @private */ computeSinkIsLaunching_: function(currentLaunchingSinkId, sinkId) { return currentLaunchingSinkId == sinkId; }, /** * @param {!Array} sinksToShow The list of sinks. * @return {boolean} Whether or not to hide the sink list. * @private */ computeSinkListHidden_: function(sinksToShow) { return sinksToShow.length == 0; }, /** * @param {?media_router.MediaRouterView} view The current view. * @param {?media_router.Issue} issue The current issue. * @return {boolean} Whether or not to hide entire the sink list view. * @private */ computeSinkListViewHidden_: function(view, issue) { return (view != media_router.MediaRouterView.SINK_LIST && view != media_router.MediaRouterView.FILTER) || (!!issue && issue.isBlocking); }, /** * Returns whether the sink domain for |sink| should be hidden. * @param {!media_router.Sink} sink * @return {boolean} |true| if the domain should be hidden. * @private */ computeSinkDomainHidden_: function(sink) { return !this.showDomain || this.isEmptyOrWhitespace_(sink.domain); }, /** * Computes which portions of a sink name, if any, should be highlighted when * displayed in the filter view. Any substrings matching the search text * should be highlighted. * * The order the strings are combined is plainText[0] highlightedText[0] * plainText[1] highlightedText[1] etc. * * @param {!{sinkItem: !media_router.Sink, * substrings: !Array>}} matchedItem * Parameters in matchedItem: * sinkItem - Original !media_router.Sink from the sink list. * substrings - List of index pairs denoting substrings of sinkItem.name * that match |searchInputText_|. * @return {!{highlightedText: !Array, plainText: !Array}} * highlightedText - Array of strings that should be displayed highlighted. * plainText - Array of strings that should be displayed normally. * @private */ computeSinkMatchingText_: function(matchedItem) { if (!matchedItem.substrings) { return {highlightedText: [null], plainText: [matchedItem.sinkItem.name]}; } var lastMatchIndex = -1; var nameIndex = 0; var sinkName = matchedItem.sinkItem.name; var highlightedText = []; var plainText = []; for (var i = 0; i < matchedItem.substrings.length; ++i) { var [matchStart, matchEnd] = matchedItem.substrings[i]; if (lastMatchIndex + 1 < matchStart) { plainText.push(sinkName.substring(lastMatchIndex + 1, matchStart)); } else { plainText.push(null); } highlightedText.push(sinkName.substring(matchStart, matchEnd + 1)); lastMatchIndex = matchEnd; } if (lastMatchIndex + 1 < sinkName.length) { highlightedText.push(null); plainText.push(sinkName.substring(lastMatchIndex + 1)); } return {highlightedText: highlightedText, plainText: plainText}; }, /** * Returns the subtext to be shown for |sink|. Only called if * |computeSinkSubtextHidden_| returns false for the same |sink| and * |sinkToRouteMap|. * @param {!media_router.Sink} sink * @param {!Object} sinkToRouteMap * @return {?string} The subtext to be shown. * @private */ computeSinkSubtext_: function(sink, sinkToRouteMap) { var route = sinkToRouteMap[sink.id]; if (route && !this.isEmptyOrWhitespace_(route.description)) return route.description; return sink.description; }, /** * Returns whether the sink subtext for |sink| should be hidden. * @param {!media_router.Sink} sink * @param {!Object} sinkToRouteMap * @return {boolean} |true| if the subtext should be hidden. * @private */ computeSinkSubtextHidden_: function(sink, sinkToRouteMap) { if (!this.isEmptyOrWhitespace_(sink.description)) return false; var route = sinkToRouteMap[sink.id]; return !route || this.isEmptyOrWhitespace_(route.description); }, /** * @param {boolean} justOpened Whether the MR UI was just opened. * @return {boolean} Whether or not to hide the spinner. * @private */ computeSpinnerHidden_: function(justOpened) { return !justOpened; }, /** * Computes the height of the sink list view element when search results are * being shown. * * @param {?Element} deviceMissing No devices message element. * @param {?Element} noMatches No search matches element. * @param {?Element} results Search results list element. * @param {number} searchOffsetHeight Search input container element height. * @param {number} maxHeight Max height of the list elements. * @return {number} The height of the sink list view when search results are * being shown. * @private */ computeTotalSearchHeight_: function( deviceMissing, noMatches, results, searchOffsetHeight, maxHeight) { var contentHeight = deviceMissing.offsetHeight + ((noMatches.hasAttribute('hidden')) ? results.offsetHeight : noMatches.offsetHeight); return Math.min(contentHeight, maxHeight) + searchOffsetHeight; }, /** * Updates element positioning when the view changes and possibly triggers * reporting of a user filter action. If there is no filter text, it defers * the reporting until some text is entered, but otherwise it reports the * filter action here. * @param {?media_router.MediaRouterView} currentView The current view of the * dialog. * @param {?media_router.MediaRouterView} previousView The previous * |currentView|. * @private */ currentViewChanged_: function(currentView, previousView) { if (currentView == media_router.MediaRouterView.FILTER) { this.reportFilterOnInput_ = true; this.maybeReportFilter_(); } this.updateElementPositioning_(); if (previousView == media_router.MediaRouterView.ROUTE_DETAILS) { media_router.browserApi.onMediaControllerClosed(); if (this.$$('route-details')) this.$$('route-details').onClosed(); } }, /** * Filters all sinks based on fuzzy matching to the currently entered search * text. * @param {string} searchInputText The currently entered search text. * @private */ filterSinks_: function(searchInputText) { if (searchInputText.length == 0) { this.searchResultsToShow_ = this.sinksToShow_.map(function(item) { return {sinkItem: item, substrings: null}; }); return; } var searchResultsToShow = []; for (var i = 0; i < this.sinksToShow_.length; ++i) { var matchSubstrings = this.computeSearchMatches_( searchInputText, this.sinksToShow_[i].name); if (!matchSubstrings) { continue; } searchResultsToShow.push( {sinkItem: this.sinksToShow_[i], substrings: matchSubstrings}); } searchResultsToShow.sort(this.compareSearchMatches_); var pendingPseudoSink = (this.pseudoSinkSearchState_) ? this.pseudoSinkSearchState_.getPseudoSink() : null; // We may need to add pseudo sinks to the filter results. A pseudo sink will // be shown if there is no real sink with the same icon and name exactly // matching the filter text. The map() call transforms any pseudo sink // objects that will be shown to the search result format, where we know // that the entire sink name will be a match. // // The exception to this is when there is a pending pseudo sink search. Then // the pseudo sink for the search will be treated like a real sink because // it will actually be in |sinksToShow_| until a real sink is returned by // search. So the filter here shouldn't treat it like a pseudo sink. searchResultsToShow = this.pseudoSinks_ .filter(function(pseudoSink) { return (!pendingPseudoSink || pseudoSink.id != pendingPseudoSink.id) && !searchResultsToShow.find(function(searchResult) { return searchResult.sinkItem.name == searchInputText && searchResult.sinkItem.iconType == pseudoSink.iconType; }); }) .map(function(pseudoSink) { pseudoSink.name = searchInputText; return { sinkItem: pseudoSink, substrings: [[0, searchInputText.length - 1]] }; }) .concat(searchResultsToShow); this.searchResultsToShow_ = searchResultsToShow; }, /** * Helper function to locate the CastMode object with the given type in * castModeList. * * @param {number} castModeType Type of cast mode to look for. * @return {media_router.CastMode|undefined} CastMode object with the given * type in castModeList, or undefined if not found. * @private */ findCastModeByType_: function(castModeType) { return this.castModeList.find(function(element, index, array) { return element.type == castModeType; }); }, /** * Helper function to locate the position in the |castModeList| of the * CastMode object with the given type. * * @param {number} castModeType Type of cast mode to look for. * @return {number} index of the given type, or -1 if not found. * @private */ findCastModeIndexByType_: function(castModeType) { return this.castModeList .map(function(element) { return element.type; }) .indexOf(castModeType); }, /** * Helper function to return a forced CastMode, if any. * * @return {media_router.CastMode|undefined} CastMode object with * isForced = true, or undefined if not found. * @private */ findForcedCastMode_: function() { return this.castModeList && this.castModeList.find(element => element.isForced); }, /** * @param {?Element} element Element to compute padding for. * @return {!Array} Array containing the element's bottom padding * value and the element's top padding value, in that order. * @private */ getElementVerticalPadding_: function(element) { var style = window.getComputedStyle(element); return [ parseInt(style.getPropertyValue('padding-bottom'), 10) || 0, parseInt(style.getPropertyValue('padding-top'), 10) || 0 ]; }, /** * Retrieves the first run flow cloud preferences text, if it exists. On * non-officially branded builds, the string is not defined. * * @return {string} Cloud preferences text. */ getFirstRunFlowCloudPrefText_: function() { return loadTimeData.valueExists('firstRunFlowCloudPrefText') ? this.i18n('firstRunFlowCloudPrefText') : ''; }, /** * @param {?media_router.Route} route Route to get the sink for. * @return {?media_router.Sink} Sink associated with |route| or * undefined if we don't have data for the sink. */ getSinkForRoute_: function(route) { return route ? this.sinkMap_[route.sinkId] : null; }, /** * @param {?Element} element Conditionally-templated element to check. * @return {boolean} Whether |element| is considered present in the document * as a conditionally-templated element. This does not check the |hidden| * attribute. */ hasConditionalElement_: function(element) { return !!element && (!element.style.display || element.style.display != 'none'); }, /** * Returns whether given string is undefined, null, empty, or whitespace only. * @param {?string} str String to be tested. * @return {boolean} |true| if the string is undefined, null, empty, or * whitespace. * @private */ isEmptyOrWhitespace_: function(str) { return str === undefined || str === null || (/^\s*$/).test(str); }, /** * Reports a user filter action if |searchInputText_| is not empty and the * filter action hasn't been reported since the view changed to the filter * view. * @private */ maybeReportFilter_: function() { if (this.reportFilterOnInput_ && this.searchInputText_.length != 0) { this.reportFilterOnInput_ = false; this.fire('report-filter'); } }, /** * Updates |currentView_| if the dialog had just opened and there's * only one local route. */ maybeShowRouteDetailsOnOpen: function() { var localRoute = null; for (var i = 0; i < this.routeList.length; i++) { var route = this.routeList[i]; if (!route.isLocal) continue; if (!localRoute) { localRoute = route; } else { // Don't show route details if there are more than one local route. localRoute = null; break; } } if (localRoute) this.showRouteDetails_(localRoute); this.fire('show-initial-state', {currentView: this.currentView_}); }, /** * Updates |currentView_| if there is a new blocking issue or a blocking * issue is resolved. Clears any pending route creation properties if the * issue corresponds with |pendingCreatedRouteId_|. * * @param {?media_router.Issue} issue The new issue, or null if the * blocking issue was resolved. * @private */ maybeShowIssueView_: function(issue) { if (!!issue) { if (issue.isBlocking) { this.currentView_ = media_router.MediaRouterView.ISSUE; } else if (this.currentView_ == media_router.MediaRouterView.SINK_LIST) { // Make space for the non-blocking issue in the sink list. this.updateElementPositioning_(); } } else if (this.currentView_ == media_router.MediaRouterView.ISSUE) { // Switch back to the sink list if the issue was cleared and it was // showing an issue. It is expected that the only way to clear an issue is // by user action; the IssueManager (C++ side) does not clear issues in // the UI. this.showSinkList_(); } if (!!this.pendingCreatedRouteId_ && !!issue && issue.routeId == this.pendingCreatedRouteId_) { this.resetRouteCreationProperties_(false); } }, /** * If an element in the search results list has keyboard focus when we are * transitioning from the filter view to the sink list view, give focus to the * same sink in the sink list. Otherwise we leave the keyboard focus where it * is. * @private */ maybeUpdateFocusOnFilterViewExit_: function() { var searchSinks = this.$$('#search-results').querySelectorAll('paper-item'); var focusedElem = Array.prototype.find.call(searchSinks, function(sink) { return sink.focused; }); if (!focusedElem) { return; } var focusedSink = this.$$('#searchResults').itemForElement(focusedElem).sinkItem; setTimeout(function() { var sinkListPaperMenu = this.$$('#sink-list-paper-menu'); var sinks = sinkListPaperMenu.children; var sinkList = this.$$('#sinkList'); for (var i = 0; i < sinks.length; i++) { if (sinkList.itemForElement(sinks[i]).id == focusedSink.id) { sinkListPaperMenu.selectIndex(i); break; } } }.bind(this)); }, /** * May update |populatedSinkListSeenTimeMs_| depending on |currentView| and * |sinksToShow|. * Called when |currentView_| or |sinksToShow_| is updated. * * @param {?media_router.MediaRouterView} currentView The current view of the * dialog. * @param {!Array} sinksToShow The sinks to display. * @private */ maybeUpdateStartSinkDisplayStartTime_: function(currentView, sinksToShow) { if (currentView == media_router.MediaRouterView.SINK_LIST && sinksToShow.length != 0) { // Only set |populatedSinkListSeenTimeMs_| if it has not already been set. if (this.populatedSinkListSeenTimeMs_ == -1) this.populatedSinkListSeenTimeMs_ = window.performance.now(); } else { // Reset |populatedSinkListLastSeen_| if the sink list isn't being shown // or if there aren't any sinks available for display. this.populatedSinkListSeenTimeMs_ = -1; } }, /** * Animates the transition from the filter view, where the search field is at * the top of the list, to the sink list view, where the search field is at * the bottom of the list. * * If this is called while another animation is in progress, it queues itself * to be run at the end of the current animation. * * @param {!function()} resolve Resolves the animation promise that is waiting * on this animation. * @private */ moveSearchToBottom_: function(resolve) { var deviceMissing = this.$['device-missing']; var list = this.$$('#sink-list'); var resultsContainer = this.$$('#search-results-container'); var search = this.$$('#sink-search'); var view = this.$['sink-list-view']; var hasList = this.hasConditionalElement_(list); var initialHeight = view.offsetHeight; // Force the view height to be max dialog height. view.style['overflow'] = 'hidden'; var searchInitialOffsetHeight = search.offsetHeight; var searchInitialPaddingBottom, searchInitialPaddingTop; [searchInitialPaddingBottom, searchInitialPaddingTop] = this.getElementVerticalPadding_(search); var searchPadding = searchInitialPaddingBottom + searchInitialPaddingTop; var searchHeight = search.offsetHeight - searchPadding; var searchFinalPaddingBottom, searchFinalPaddingTop; [searchFinalPaddingBottom, searchFinalPaddingTop] = this.getElementVerticalPadding_(search); var searchFinalOffsetHeight = searchHeight + searchFinalPaddingBottom + searchFinalPaddingTop; var resultsInitialTop = 0; var finalHeight = 0; // Get final view height ahead of animation. if (hasList) { list.style['position'] = 'absolute'; list.style['opacity'] = '0'; this.hideSinkListForAnimation_ = false; finalHeight += list.offsetHeight; list.style['position'] = 'relative'; } else { resultsInitialTop += deviceMissing.offsetHeight + searchInitialOffsetHeight; finalHeight += deviceMissing.offsetHeight; } var searchInitialTop = hasList ? 0 : deviceMissing.offsetHeight; var searchFinalTop = hasList ? list.offsetHeight - search.offsetHeight : deviceMissing.offsetHeight; resultsContainer.style['position'] = 'absolute'; var duration = this.computeAnimationDuration_(searchFinalTop - searchInitialTop); var timing = {duration: duration, easing: 'ease-in-out', fill: 'forwards'}; // This GroupEffect does the reverse of |moveSearchToTop_|. It fades the // sink list in while sliding the search input and search results list down. // The dialog height is also adjusted smoothly to the sink list height. var deviceMissingEffect = new KeyframeEffect( deviceMissing, [ {'marginBottom': searchInitialOffsetHeight}, {'marginBottom': searchFinalOffsetHeight} ], timing); var listEffect = new KeyframeEffect(list, [{'opacity': '0'}, {'opacity': '1'}], timing); var resultsEffect = new KeyframeEffect( resultsContainer, [ { 'top': resultsInitialTop + 'px', 'paddingTop': resultsContainer.style['padding-top'] }, {'top': '100%', 'paddingTop': '0px'} ], timing); var searchEffect = new KeyframeEffect( search, [ { 'top': searchInitialTop + 'px', 'marginTop': '0px', 'paddingBottom': searchInitialPaddingBottom + 'px', 'paddingTop': searchInitialPaddingTop + 'px' }, { 'top': '100%', 'marginTop': '-' + searchFinalOffsetHeight + 'px', 'paddingBottom': searchFinalPaddingBottom + 'px', 'paddingTop': searchFinalPaddingTop + 'px' } ], timing); var viewEffect = new KeyframeEffect( view, [ {'height': initialHeight + 'px', 'paddingBottom': '0px'}, { 'height': finalHeight + 'px', 'paddingBottom': searchFinalOffsetHeight + 'px' } ], timing); var player = document.timeline.play(new GroupEffect( hasList ? [listEffect, resultsEffect, searchEffect, viewEffect] : [deviceMissingEffect, resultsEffect, searchEffect, viewEffect])); var that = this; var finalizeAnimation = function() { view.style['overflow'] = ''; that.putSearchAtBottom_(); that.filterTransitionPlayer_.cancel(); that.filterTransitionPlayer_ = null; that.isSearchListHidden_ = true; resolve(); }; player.finished.then(finalizeAnimation); this.filterTransitionPlayer_ = player; }, /** * Animates the transition from the sink list view, where the search field is * at the bottom of the list, to the filter view, where the search field is at * the top of the list. * * If this is called while another animation is in progress, it queues itself * to be run at the end of the current animation. * * @param {!function()} resolve Resolves the animation promise that is waiting * on this animation. * @private */ moveSearchToTop_: function(resolve) { var deviceMissing = this.$['device-missing']; var list = this.$$('#sink-list'); var noMatches = this.$$('#no-search-matches'); var results = this.$$('#search-results'); var resultsContainer = this.$$('#search-results-container'); var search = this.$$('#sink-search'); var view = this.$['sink-list-view']; // Set the max height for the results list before it's shown. results.style.maxHeight = this.sinkListMaxHeight_ + 'px'; // Saves current search container |offsetHeight| which includes bottom // padding. var searchInitialOffsetHeight = search.offsetHeight; var hasList = this.hasConditionalElement_(list); var searchInitialTop = hasList ? list.offsetHeight - searchInitialOffsetHeight : deviceMissing.offsetHeight; var searchFinalTop = hasList ? 0 : deviceMissing.offsetHeight; var searchInitialPaddingBottom, searchInitialPaddingTop; [searchInitialPaddingBottom, searchInitialPaddingTop] = this.getElementVerticalPadding_(search); var searchPadding = searchInitialPaddingBottom + searchInitialPaddingTop; var searchHeight = search.offsetHeight - searchPadding; var searchFinalPaddingBottom, searchFinalPaddingTop; [searchFinalPaddingBottom, searchFinalPaddingTop] = this.getElementVerticalPadding_(search); var searchFinalOffsetHeight = searchHeight + searchFinalPaddingBottom + searchFinalPaddingTop; // Omitting |search.offsetHeight| because it is handled by view animation // separately. var initialHeight = hasList ? list.offsetHeight : deviceMissing.offsetHeight; view.style['overflow'] = 'hidden'; var resultsPadding = this.computeElementVerticalPadding_(results); var finalHeight = this.computeTotalSearchHeight_( deviceMissing, noMatches, results, searchFinalOffsetHeight, this.sinkListMaxHeight_ + resultsPadding); var duration = this.computeAnimationDuration_(searchFinalTop - searchInitialTop); var timing = {duration: duration, easing: 'ease-in-out', fill: 'forwards'}; // This GroupEffect will cause the sink list to fade out while the search // input and search results list slide up. The dialog will also resize // smoothly to the new search result list height. var deviceMissingEffect = new KeyframeEffect( deviceMissing, [ {'marginBottom': searchInitialOffsetHeight}, {'marginBottom': searchFinalOffsetHeight} ], timing); var listEffect = new KeyframeEffect(list, [{'opacity': '1'}, {'opacity': '0'}], timing); var resultsEffect = new KeyframeEffect( resultsContainer, [ {'top': '100%', 'paddingTop': '0px'}, { 'top': searchFinalTop + 'px', 'paddingTop': searchFinalOffsetHeight + 'px' } ], timing); var searchEffect = new KeyframeEffect( search, [ { 'top': '100%', 'marginTop': '-' + searchInitialOffsetHeight + 'px', 'paddingBottom': searchInitialPaddingBottom + 'px', 'paddingTop': searchInitialPaddingTop + 'px' }, { 'top': searchFinalTop + 'px', 'marginTop': '0px', 'paddingBottom': searchFinalPaddingBottom + 'px', 'paddingTop': searchFinalPaddingTop + 'px' } ], timing); var viewEffect = new KeyframeEffect( view, [ { 'height': initialHeight + 'px', 'paddingBottom': searchInitialOffsetHeight + 'px' }, {'height': finalHeight + 'px', 'paddingBottom': '0px'} ], timing); var player = document.timeline.play(new GroupEffect( hasList ? [listEffect, resultsEffect, searchEffect, viewEffect] : [deviceMissingEffect, resultsEffect, searchEffect, viewEffect])); var that = this; var finalizeAnimation = function() { // When we are moving the search results up into view, the user may type // more text or delete text which may change the height of the search // results list. In this case, the dialog height that the animation ends // on will now be wrong. In order to correct this smoothly, // |putSearchAtTop_| will queue another animation just to adjust the // dialog height. // // The |filterTransitionPlayer_| will hold all of the animated elements in // their final keyframe state until it is canceled or another player // overrides it because we used |fill: 'forwards'| in all of the effects. // So unlike |moveSearchToBottom_|, we don't know for sure whether we want // to cancel |filterTransitionPlayer_| after |putSearchAtTop_| because // another animation may have been run to correct the dialog height. // // If |putSearchAtTop_| has to adjust the dialog height, it also queues // itself to run again when that animation is finished. When the height is // finally correct at the end of an animation, it will cancel // |filterTransitionPlayer_| itself. that.putSearchAtTop_(resolve); }; player.finished.then(finalizeAnimation); this.filterTransitionPlayer_ = player; }, /** * Handles a cast mode selection. Updates |headerText|, |headerTextTooltip|, * and |shownCastModeValue_|. * * @param {!Event} event The event object. * @private */ onCastModeClick_: function(event) { // The clicked cast mode can come from one of three lists, // presentationCastModeList, shareScreenCastModeList, and // localMediaCastModeList. var clickedMode = this.$$('#presentationCastModeList').itemForElement(event.target) || this.$$('#shareScreenCastModeList').itemForElement(event.target) || this.$$('#localMediaCastModeList').itemForElement(event.target); if (!clickedMode) return; // If the user selects LOCAL_FILE, some additional steps are required // (selecting the file), before the cast mode has been officially // selected. if (clickedMode.type == media_router.CastModeType.LOCAL_FILE) { this.selectLocalMediaFile_(); } else { this.castModeSelected_(clickedMode); } }, /** * Handles a change-route-source-click event. Sets the currently launching * sink to be the current route's sink and shows the sink list. * * @param {!Event} event The event object. * Parameters in |event|.detail: * route - route to modify. * selectedCastMode - cast mode to use for the new source. * @private */ onChangeRouteSourceClick_: function(event) { /** @type {{route: !media_router.Route, selectedCastMode: number}} */ var detail = event.detail; this.currentLaunchingSinkId_ = detail.route.sinkId; var sink = this.sinkMap_[detail.route.sinkId]; this.showSinkList_(); this.maybeReportUserFirstAction( media_router.MediaRouterUserAction.REPLACE_LOCAL_ROUTE); }, /** * Handles a close-route event. Shows the sink list and starts a timer to * close the dialog if there is no click within three seconds. * * @param {!Event} event The event object. * Parameters in |event|.detail: * route - route to close. * @private */ onCloseRoute_: function(event) { /** @type {{route: media_router.Route}} */ var detail = event.detail; this.showSinkList_(); this.startTapTimer_(); if (detail.route.isLocal) { this.maybeReportUserFirstAction( media_router.MediaRouterUserAction.STOP_LOCAL); } }, /** * Handles response of previous create route attempt. * * @param {string} sinkId The ID of the sink to which the Media Route was * creating a route. * @param {?media_router.Route} route The newly created route that * corresponds to the sink if route creation succeeded; null otherwise. * @param {boolean} isForDisplay Whether or not |route| is for display. */ onCreateRouteResponseReceived: function(sinkId, route, isForDisplay) { // The provider will handle sending an issue for a failed route request. if (!route) { this.resetRouteCreationProperties_(false); this.fire('report-resolved-route', { outcome: media_router.MediaRouterRouteCreationOutcome.FAILURE_NO_ROUTE }); return; } // Check that |sinkId| exists and corresponds to |currentLaunchingSinkId_|. if (!this.sinkMap_[sinkId] || this.currentLaunchingSinkId_ != sinkId) { this.fire('report-resolved-route', { outcome: media_router.MediaRouterRouteCreationOutcome.FAILURE_INVALID_SINK }); return; } // Regardless of whether the route is for display, it was resolved // successfully. this.fire( 'report-resolved-route', {outcome: media_router.MediaRouterRouteCreationOutcome.SUCCESS}); if (isForDisplay) { this.showRouteDetails_(route); this.startTapTimer_(); this.resetRouteCreationProperties_(true); } else { this.pendingCreatedRouteId_ = route.id; } }, /** * Sets up the LOCAL_FILE cast mode for display after a specific file has been * selected. * * @param {string} fileName The name of the file that has been selected. */ onFileDialogSuccess(fileName) { /** @const */ var mode = this.findCastModeByType_(media_router.CastModeType.LOCAL_FILE); if (!mode) return; this.castModeSelected_(mode); this.headerText = loadTimeData.getStringF('castLocalMediaSelectedFileTitle', fileName); this.updateSelectedCastModeMenuItem_(); }, /** * Called when a focus event is triggered. * * @param {!Event} event The event object. * @private */ onFocus_: function(event) { // If the focus event was automatically fired by Polymer, remove focus from // the element. This prevents unexpected focusing when the dialog is // initially loaded. This only happens on mac. if (cr.isMac && !event.sourceCapabilities) { // Adding a focus placeholder element is part of the workaround for // handling unexpected focusing, which only happens once on dialog open. // Since the placeholder is focus-enabled as denoted by its tabindex // value, the focus will not appear in other elements. var placeholder = this.$$('#focus-placeholder'); // Check that the placeholder is the currently focused element. In some // tests, other elements are non-user-triggered focused. if (placeholder && this.shadowRoot.activeElement == placeholder) { event.path[0].blur(); // Remove the placeholder since we have no more use for it. placeholder.remove(); } } }, /** * Called when a keydown event is fired. * @param {!Event} e Keydown event object for the event. */ onKeydown_: function(e) { // The ESC key may be pressed with a combination of other keys. It is // handled on the C++ side instead of the JS side on non-mac platforms, // which uses toolkit-views. Handle the expected behavior on all platforms // here. if (e.key == media_router.KEY_ESC && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { // When searching, allow ESC as a mechanism to leave the filter view. if (this.currentView_ == media_router.MediaRouterView.FILTER) { // If the user tabbed to an item in the search results, or otherwise has // an item in the list focused, focus will seem to vanish when we // transition back to the sink list. Instead we should move focus to the // appropriate item in the sink list. this.maybeUpdateFocusOnFilterViewExit_(); this.showSinkList_(); e.preventDefault(); } else { this.fire('close-dialog', { pressEscToClose: true, }); } } }, /** * Called when a mouseleave event is triggered. * * @private */ onMouseLeave_: function() { this.mouseIsPositionedOverDialog_ = false; }, /** * Called when a mouseenter event is triggered. * * @private */ onMouseEnter_: function() { this.mouseIsPositionedOverDialog_ = true; }, /** * Called when a search has completed up to route creation. |sinkId| * identifies the sink that should be in |allSinks|, if a sink was found. * * @param {string} sinkId The ID of the sink that is the result of the * currently pending search. */ onReceiveSearchResult: function(sinkId) { this.pseudoSinkSearchState_.receiveSinkResponse(sinkId); this.currentLaunchingSinkId_ = this.pseudoSinkSearchState_.checkForRealSink(this.allSinks); this.rebuildSinksToShow_(); // If we're in filter view, make sure the |sinksToShow_| change is picked // up. if (this.currentView_ == media_router.MediaRouterView.FILTER) { this.filterSinks_(this.searchInputText_); } }, /** * Called when the connection to the route controller is invalidated. Switches * from route details view to the sink list view. */ onRouteControllerInvalidated: function() { if (this.currentView_ == media_router.MediaRouterView.ROUTE_DETAILS) { this.currentRoute_ = null; this.showSinkList_(); } }, /** * Called when a sink is clicked. * * @param {!Event} event The event object. * @private */ onSinkClick_: function(event) { var clickedSink = (this.currentView_ == media_router.MediaRouterView.FILTER) ? this.$$('#searchResults').itemForElement(event.target).sinkItem : this.$$('#sinkList').itemForElement(event.target); this.showOrCreateRoute_(clickedSink); this.fire('sink-click', {index: event['model'].index}); }, /** * Sets the positioning of the sink list, search input, and search results so * that everything is in the correct state for the sink list view. * * @private */ putSearchAtBottom_: function() { var search = this.$$('#sink-search'); if (!this.hasConditionalElement_(search)) { return; } var deviceMissing = this.$['device-missing']; var list = this.$$('#sink-list'); var resultsContainer = this.$$('#search-results-container'); var view = this.$['sink-list-view']; search.style['top'] = ''; if (resultsContainer) { resultsContainer.style['position'] = ''; resultsContainer.style['padding-top'] = ''; resultsContainer.style['top'] = ''; } this.hideSinkListForAnimation_ = false; var hasList = this.hasConditionalElement_(list); if (hasList) { search.style['margin-top'] = '-' + search.offsetHeight + 'px'; view.style['padding-bottom'] = search.offsetHeight + 'px'; list.style['opacity'] = ''; } else { var bottomMargin = 12; deviceMissing.style['margin-bottom'] = (search.offsetHeight + bottomMargin) + 'px'; search.style['margin-top'] = ''; view.style['padding-bottom'] = ''; } }, /** * Sets the positioning of the sink list, search input, and search results so * that everything is in the correct state for the filter view. * * If the user was searching while the |moveSearchToTop_| animation was * happening then the dialog height that animation ends at could be different * than the current height of the search results. If this is the case, this * function first spawns a new animation that smoothly corrects the height * problem. This is iterative, but once we enter a call where the heights * match up, the elements will become static again. * * @param {!function()} resolve Resolves the animation promise that is waiting * on this animation. * @private */ putSearchAtTop_: function(resolve) { var deviceMissing = this.$['device-missing']; var list = this.$$('#sink-list'); var noMatches = this.$$('#no-search-matches'); var results = this.$$('#search-results'); var resultsContainer = this.$$('#search-results-container'); var search = this.$$('#sink-search'); var view = this.$['sink-list-view']; // Set the max height for the results list before it's shown. results.style.maxHeight = this.sinkListMaxHeight_ + 'px'; // If there is a height mismatch between where the animation calculated the // height should be and where it is now because the search results changed // during the animation, correct it with... another animation. var resultsPadding = this.computeElementVerticalPadding_(results); var finalHeight = this.computeTotalSearchHeight_( deviceMissing, noMatches, results, search.offsetHeight, this.sinkListMaxHeight_ + resultsPadding); if (finalHeight != view.offsetHeight) { var viewEffect = new KeyframeEffect( view, [ {'height': view.offsetHeight + 'px'}, {'height': finalHeight + 'px'} ], { duration: this.computeAnimationDuration_(finalHeight - view.offsetHeight), easing: 'ease-in-out', fill: 'forwards' }); var player = document.timeline.play(viewEffect); if (this.heightAdjustmentPlayer_) { this.heightAdjustmentPlayer_.cancel(); } this.heightAdjustmentPlayer_ = player; player.finished.then(this.putSearchAtTop_.bind(this, resolve)); return; } var hasList = this.hasConditionalElement_(list); search.style['margin-top'] = ''; deviceMissing.style['margin-bottom'] = search.offsetHeight + 'px'; var searchFinalTop = hasList ? 0 : deviceMissing.offsetHeight; var resultsPaddingTop = hasList ? search.offsetHeight + 'px' : '0px'; search.style['top'] = searchFinalTop + 'px'; this.hideSinkListForAnimation_ = true; resultsContainer.style['position'] = 'relative'; resultsContainer.style['padding-top'] = resultsPaddingTop; resultsContainer.style['top'] = ''; view.style['overflow'] = ''; view.style['padding-bottom'] = ''; if (this.filterTransitionPlayer_) { this.filterTransitionPlayer_.cancel(); this.filterTransitionPlayer_ = null; } if (this.heightAdjustmentPlayer_) { this.heightAdjustmentPlayer_.cancel(); this.heightAdjustmentPlayer_ = null; } resolve(); }, /** * Queues a call to |moveSearchToBottom_| by adding it as a continuation to * |animationPromise_| and updating |animationPromise_|. */ queueMoveSearchToBottom_: function() { var oldPromise = this.animationPromise_; var that = this; this.animationPromise_ = new Promise(function(resolve) { oldPromise.then(that.moveSearchToBottom_.bind(that, resolve)); }); }, /** * Queues a call to |moveSearchToTop_| by adding it as a continuation to * |animationPromise_| and updating |animationPromise_|. The new promise will * not resolve until |putSearchAtTop_| is finished, including any potential * dialog height adjustment animations. */ queueMoveSearchToTop_: function() { var oldPromise = this.animationPromise_; var that = this; this.animationPromise_ = new Promise(function(resolve) { oldPromise.then(function() { that.isSearchListHidden_ = false; setTimeout(that.moveSearchToTop_.bind(that, resolve)); }); }); }, /** * Queues a call to |putSearchAtTop_| by adding it as a continuation to * |animationPromise_| and updating |animationPromise_|. */ queuePutSearchAtTop_: function() { var that = this; var oldPromise = this.animationPromise_; this.animationPromise_ = new Promise(function(resolve) { oldPromise.then(that.putSearchAtTop_.bind(that, resolve)); }); }, /** * Called when |routeList| is updated. Rebuilds |routeMap_| and * |sinkToRouteMap_|. * * @private */ rebuildRouteMaps_: function() { this.routeMap_ = {}; // Rebuild |sinkToRouteMap_| with a temporary map to avoid firing the // computed functions prematurely. var tempSinkToRouteMap = {}; // We expect that each route in |routeList| maps to a unique sink. this.routeList.forEach(function(route) { this.routeMap_[route.id] = route; tempSinkToRouteMap[route.sinkId] = route; }, this); // If there is route creation in progress, check if any of the route ids // correspond to |pendingCreatedRouteId_|. If so, the newly created route // is ready to be displayed; switch to route details view. if (this.currentLaunchingSinkId_ != '' && this.pendingCreatedRouteId_ != '') { var route = tempSinkToRouteMap[this.currentLaunchingSinkId_]; if (route && this.pendingCreatedRouteId_ == route.id) { this.showRouteDetails_(route); this.startTapTimer_(); this.resetRouteCreationProperties_(true); } } else { // If |currentRoute_| is no longer active, clear |currentRoute_|. Also // switch back to the SINK_PICKER view if the user is currently in the // ROUTE_DETAILS view. if (this.currentRoute_) { this.currentRoute_ = this.routeMap_[this.currentRoute_.id] || null; } if (!this.currentRoute_ && this.currentView_ == media_router.MediaRouterView.ROUTE_DETAILS) { this.showSinkList_(); } } this.sinkToRouteMap_ = tempSinkToRouteMap; this.rebuildSinksToShow_(); }, /** * Rebuilds the list of sinks to be shown for the current cast mode. * A sink should be shown if it is compatible with the current cast mode, or * if the sink is associated with a route. The resulting list is sorted by * name. */ rebuildSinksToShow_: function() { var updatedSinkList = this.allSinks.filter(function(sink) { return !sink.isPseudoSink; }, this); if (this.pseudoSinkSearchState_) { var pendingPseudoSink = this.pseudoSinkSearchState_.getPseudoSink(); // Here we will treat the pseudo sink that launched the search as a real // sink until one is returned by search. This way it isn't possible to // ever reach a UI state where there is no spinner being shown in the sink // list but |currentLaunchingSinkId_| is non-empty (thereby preventing any // other sink from launching). if (pendingPseudoSink.id == this.currentLaunchingSinkId_) { updatedSinkList.unshift(pendingPseudoSink); } } // If user did not select a cast mode, then: // - If there is a forced cast mode, it is shown. // - If all sinks support only a single cast mode, then the cast mode is // switched to that mode. // - Otherwise, the cast mode becomes AUTO mode. if (!this.userHasSelectedCastMode_) this.setShownCastMode_(this.computeCastMode_()); // Non-AUTO modes may show a subset of sinks based on compatibility with the // shown value. if (this.shownCastModeValue_ != media_router.CastModeType.AUTO) { updatedSinkList = updatedSinkList.filter(function(element) { return (element.castModes & this.shownCastModeValue_) || this.sinkToRouteMap_[element.id]; }, this); } // When there's an updated list of sinks, append any new sinks to the end // of the existing list. This prevents sinks randomly jumping around the // dialog, which can surprise users / lead to inadvertently casting to the // wrong sink. if (this.sinksToShow_) { for (var i = this.sinksToShow_.length - 1; i >= 0; i--) { var index = updatedSinkList.findIndex(function(updatedSink) { return this.sinksToShow_[i].id == updatedSink.id; }.bind(this)); if (index < 0) { // Remove any sinks that are no longer discovered. this.sinksToShow_.splice(i, 1); } else { // If the sink exists, move it from |updatedSinkList| to // |sinksToShow_| in the same position, as the cast modes or other // fields may have been updated. this.sinksToShow_[i] = updatedSinkList[index]; updatedSinkList.splice(index, 1); } } updatedSinkList = this.sinksToShow_.concat(updatedSinkList); } this.sinksToShow_ = updatedSinkList; }, /** * Called when |allSinks| is updated. * * @private */ reindexSinksAndRebuildSinksToShow_: function() { this.sinkMap_ = {}; this.allSinks.forEach(function(sink) { if (!sink.isPseudoSink) { this.sinkMap_[sink.id] = sink; } }, this); if (this.pseudoSinkSearchState_) { this.currentLaunchingSinkId_ = this.pseudoSinkSearchState_.checkForRealSink(this.allSinks); } this.pseudoSinks_ = this.allSinks.filter(function(sink) { return sink.isPseudoSink && !!sink.domain; }); this.rebuildSinksToShow_(); this.searchEnabled_ = this.searchEnabled_ || this.pseudoSinks_.length > 0 || this.sinksToShow_.length >= media_router.MINIMUM_SINKS_FOR_SEARCH; this.filterSinks_(this.searchInputText_ || ''); if (this.currentView_ != media_router.MediaRouterView.FILTER) { // This code is in the unique position of seeing |animationPromise_| as // null on startup. |allSinks| is initialized before |animationPromise_| // and this listener runs when |allSinks| is initialized. if (this.animationPromise_) { this.animationPromise_ = this.animationPromise_.then(this.putSearchAtBottom_.bind(this)); } else { this.putSearchAtBottom_(); } } else { this.queuePutSearchAtTop_(); } }, /** * Resets the properties relevant to creating a new route. Fires an event * indicating whether or not route creation was successful. * Clearing |currentLaunchingSinkId_| hides the spinner indicating there is * a route creation in progress and show the device icon instead. * @param {boolean} creationSuccess Whether route creation succeeded. * * @private */ resetRouteCreationProperties_: function(creationSuccess) { this.pseudoSinkSearchState_ = null; this.currentLaunchingSinkId_ = ''; this.pendingCreatedRouteId_ = ''; // If it was a search that failed we need to refresh the filtered sinks now // that |pseudoSinkSearchState_| is null. if (!creationSuccess && this.currentView_ == media_router.MediaRouterView.FILTER) { this.filterSinks_(this.searchInputText_); } this.fire('report-route-creation', {success: creationSuccess}); }, /** * Responds to a click on the search button by toggling sink filtering. */ searchButtonClick_: function() { // Redundancy needed because focus() only fires event if input is not // already focused. In the case that user typed text, hit escape, then // clicks the search button, a focus event will not fire and so its event // handler from ready() will not run. this.showSearchResults_(); this.$$('#sink-search-input').focus(); }, /** * Initializes the position of the search input if search becomes enabled. * @param {boolean} searchEnabled The new value of |searchEnabled_|. * @private */ searchEnabledChanged_: function(searchEnabled) { if (searchEnabled) { this.async(function() { this.setSearchFocusHandlers_(); this.putSearchAtBottom_(); }); } }, /** * Filters the sink list when the input text changes and shows the search * results if |searchInputText| is not empty. * @param {string} searchInputText The currently entered search text. * @private */ searchInputTextChanged_: function(searchInputText) { this.filterSinks_(searchInputText); if (searchInputText.length != 0) { this.showSearchResults_(); this.maybeReportFilter_(); } }, /** * Sets the selected cast mode to the one associated with |castModeType|, * and rebuilds sinks to reflect the change. * @param {number} castModeType The type of the selected cast mode. */ selectCastMode: function(castModeType) { var castMode = this.findCastModeByType_(castModeType); if (castMode && castModeType != this.shownCastModeValue_) { this.setShownCastMode_(castMode); this.userHasSelectedCastMode_ = true; this.rebuildSinksToShow_(); } }, /** * Fires the command to open a file dialog. * * @private */ selectLocalMediaFile_() { this.fire('select-local-media-file'); }, /** * Sets various focus and blur event handlers to handle showing search results * when the search input is focused. * @private */ setSearchFocusHandlers_: function() { var searchInput = this.$$('#sink-search-input'); var that = this; // The window can see a blur event for two important cases: the window is // actually losing focus or keyboard focus is wrapping from the end of the // document to the beginning. To handle both cases, we save whether the // search input was focused during the window blur event. // // When the search input receives focus, it could be as part of window // focus. If the search input was also focused on window blur, it shouldn't // show search results if they aren't already being shown. Otherwise, // focusing the search input should activate the FILTER view by calling // |showSearchResults_()|. window.addEventListener('blur', function() { that.isSearchFocusedOnWindowBlur_ = that.shadowRoot.activeElement == searchInput; }); searchInput.addEventListener('focus', function() { if (!that.isSearchFocusedOnWindowBlur_) { that.showSearchResults_(); } }); }, /** * Updates the shown cast mode, and updates the header text fields * according to the cast mode. If |castMode| type is AUTO, then set * |userHasSelectedCastMode_| to false. * * @param {!media_router.CastMode} castMode */ setShownCastMode_: function(castMode) { if (this.shownCastModeValue_ == castMode.type) return; this.shownCastModeValue_ = castMode.type; this.headerText = castMode.description; this.headerTextTooltip = castMode.host || ''; if (castMode.type == media_router.CastModeType.AUTO) this.userHasSelectedCastMode_ = false; }, /** * Shows the cast mode list. * * @private */ showCastModeList_: function() { this.currentView_ = media_router.MediaRouterView.CAST_MODE_LIST; }, /** * Creates a new route if there is no route to the |sink| . Otherwise, * shows the route details. * * @param {!media_router.Sink} sink The sink to use. * @private */ showOrCreateRoute_: function(sink) { var route = this.sinkToRouteMap_[sink.id]; if (route) { this.showRouteDetails_(route); this.fire('navigate-sink-list-to-details'); this.maybeReportUserFirstAction( media_router.MediaRouterUserAction.STATUS_REMOTE); } else if (this.currentLaunchingSinkId_ == '') { // Allow one launch at a time. var selectedCastModeValue = this.shownCastModeValue_ == media_router.CastModeType.AUTO ? sink.castModes & -sink.castModes : this.shownCastModeValue_; if (sink.isPseudoSink) { this.pseudoSinkSearchState_ = new PseudoSinkSearchState(sink); this.fire('search-sinks-and-create-route', { id: sink.id, name: sink.name, domain: sink.domain, selectedCastMode: selectedCastModeValue }); } else { this.fire('create-route', { sinkId: sink.id, // If user selected a cast mode, then we will create a route using // that cast mode. Otherwise, the UI is in "auto" cast mode and will // use the preferred cast mode compatible with the sink. The preferred // cast mode value is the least significant bit on the bitset. selectedCastModeValue: selectedCastModeValue }); var timeToSelectSink = window.performance.now() - this.populatedSinkListSeenTimeMs_; this.fire('report-sink-click-time', {timeMs: timeToSelectSink}); } this.currentLaunchingSinkId_ = sink.id; if (sink.isPseudoSink) { this.rebuildSinksToShow_(); } this.maybeReportUserFirstAction( media_router.MediaRouterUserAction.START_LOCAL); } }, /** * Shows the route details. * * @param {!media_router.Route} route The route to show. * @private */ showRouteDetails_: function(route) { this.currentRoute_ = route; this.currentView_ = media_router.MediaRouterView.ROUTE_DETAILS; if (route.supportsWebUiController) { media_router.browserApi.onMediaControllerAvailable(route.id); } if (this.$$('route-details')) { this.$$('route-details').onOpened(); } }, /** * Shows the search results. * * @private */ showSearchResults_: function() { if (this.currentView_ != media_router.MediaRouterView.FILTER) { this.currentView_ = media_router.MediaRouterView.FILTER; this.queueMoveSearchToTop_(); } }, /** * Shows the sink list. * * @private */ showSinkList_: function() { if (this.currentView_ == media_router.MediaRouterView.FILTER) { this.queueMoveSearchToBottom_(); this.currentView_ = media_router.MediaRouterView.SINK_LIST; } else { this.currentView_ = media_router.MediaRouterView.SINK_LIST; this.putSearchAtBottom_(); } }, /** * Starts a timer which fires a close-dialog event if the user's mouse is * not positioned over the dialog after three seconds. * * @private */ startTapTimer_: function() { var id = setTimeout(function() { if (!this.mouseIsPositionedOverDialog_) this.fire('close-dialog', { pressEscToClose: false, }); }.bind(this), 3000 /* 3 seconds */); }, /** * Toggles |currentView_| between CAST_MODE_LIST and SINK_LIST. * * @private */ toggleCastModeHidden_: function() { if (this.currentView_ == media_router.MediaRouterView.CAST_MODE_LIST) { this.showSinkList_(); } else if (this.currentView_ == media_router.MediaRouterView.SINK_LIST) { this.showCastModeList_(); this.fire('navigate-to-cast-mode-list'); } }, /** * Update the position-related styling of some elements. * * @private */ updateElementPositioning_: function() { // Ensures that conditionally templated elements have finished stamping. this.async(function() { var headerHeight = this.header.offsetHeight; // Unlike the other elements whose heights are fixed, the first-run-flow // element can have a fractional height. So we use getBoundingClientRect() // to avoid rounding errors. var firstRunFlowHeight = this.$$('#first-run-flow') && this.$$('#first-run-flow').style.display != 'none' ? this.$$('#first-run-flow').getBoundingClientRect().height : 0; var issueHeight = this.$$('#issue-banner') && this.$$('#issue-banner').style.display != 'none' ? this.$$('#issue-banner').offsetHeight : 0; var search = this.$$('#sink-search'); var hasSearch = this.hasConditionalElement_(search); var searchHeight = hasSearch ? search.offsetHeight : 0; var searchPadding = hasSearch ? this.computeElementVerticalPadding_(search) : 0; this.header.style.marginTop = firstRunFlowHeight + 'px'; this.$['content'].style.marginTop = firstRunFlowHeight + headerHeight + 'px'; var sinkList = this.$$('#sink-list'); var sinkListPadding = sinkList ? this.computeElementVerticalPadding_(sinkList) : 0; this.sinkListMaxHeight_ = this.dialogHeight_ - headerHeight - firstRunFlowHeight - issueHeight - searchHeight + searchPadding - sinkListPadding; // Limit the height of the dialog to ten items, including search. var sinkItemHeight = 41; var maxSinkItems = hasSearch ? 9 : 10; this.sinkListMaxHeight_ = Math.min(sinkItemHeight * maxSinkItems, this.sinkListMaxHeight_); if (sinkList) sinkList.style.maxHeight = this.sinkListMaxHeight_ + 'px'; }); }, /** * Update the max dialog height and update the positioning of the elements. * * @param {number} height The max height of the Media Router dialog. */ updateMaxDialogHeight: function(height) { this.dialogHeight_ = height; this.updateElementPositioning_(); }, /** * Sets the selected cast mode menu item to be in sync with the current cast * mode. * @private */ updateSelectedCastModeMenuItem_: function() { /** @const */ var curIndex = this.findCastModeIndexByType_(this.shownCastModeValue_); if (this.selectedCastModeMenuItem_ != curIndex) this.selectedCastModeMenuItem_ = curIndex; }, }); /* 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. */ #arrow-drop-container { flex-grow: 1; } #arrow-drop-icon { height: var(--navigation-icon-button-size); width: var(--navigation-icon-button-size); } #back-button { height: var(--navigation-icon-button-size); width: var(--navigation-icon-button-size); } #back-button-container { -webkit-padding-end: 4px; } #close-button { -webkit-margin-start: auto; height: 31px; width: 31px; } #close-button-container { -webkit-margin-start: auto; -webkit-padding-end: 16px; -webkit-padding-start: 24px; } #header { -webkit-padding-start: 8px; align-items: center; color: white; } #header-and-arrow-container { display: flex; overflow: hidden; white-space: nowrap; } #header-text { -webkit-padding-end: 4px; font-size: 1.175em; margin: 8px; overflow: hidden; text-overflow: ellipsis; } .issue { background-color: var(--paper-red-700); } paper-icon-button { display: inline-block; } #main-container { display: flex; padding-top: 10px; } .cast-mode-list, .filter, .route-details, .sink-list { background-color: var(--paper-blue-700); } #user-email-container { -webkit-padding-start: 8px; bottom: 0; font-size: 0.917em; left: auto; padding-bottom: 12px; position: absolute; } // 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 Polymer element is used as a header for the media router interface. Polymer({ is: 'media-router-header', properties: { /** * The name of the icon used as the back button. This is set once, when * the |this| is ready. * @private {string|undefined} */ arrowDropIcon_: { type: String, }, /** * Whether or not the arrow drop icon should be disabled. * @type {boolean} */ arrowDropIconDisabled: { type: Boolean, value: false, }, /** * The header text to show. * @type {string|undefined} */ headingText: { type: String, }, /** * The height of the header when it shows the user email. * @private {number} */ headerWithEmailHeight_: { type: Number, readOnly: true, value: 62, }, /** * The height of the header when it doesn't show the user email. * @private {number} */ headerWithoutEmailHeight_: { type: Number, readOnly: true, value: 52, }, /** * Whether to show the user email in the header. * @type {boolean|undefined} */ showEmail: { type: Boolean, observer: 'maybeChangeHeaderHeight_', }, /** * The text to show in the tooltip. * @type {string|undefined} */ tooltip: { type: String, }, /** * The user email if they are signed in. * @type {string|undefined} */ userEmail: { type: String, }, /** * The current view that this header should reflect. * @type {?media_router.MediaRouterView|undefined} */ view: { type: String, observer: 'updateHeaderCursorStyle_', }, }, behaviors: [ I18nBehavior, ], ready: function() { this.$$('#header').style.height = this.headerWithoutEmailHeight_ + 'px'; }, attached: function() { // isRTL() only works after i18n_template.js runs to set . // Set the back button icon based on text direction. this.arrowDropIcon_ = isRTL() ? 'media-router:arrow-forward' : 'media-router:arrow-back'; }, /** * @param {?media_router.MediaRouterView} view The current view. * @return {string} The icon to use. * @private */ computeArrowDropIcon_: function(view) { return view == media_router.MediaRouterView.CAST_MODE_LIST ? 'media-router:arrow-drop-up' : 'media-router:arrow-drop-down'; }, /** * @param {?media_router.MediaRouterView} view The current view. * @return {boolean} Whether or not the arrow drop icon should be hidden. * @private */ computeArrowDropIconHidden_: function(view) { return view != media_router.MediaRouterView.SINK_LIST && view != media_router.MediaRouterView.CAST_MODE_LIST; }, /** * @param {?media_router.MediaRouterView} view The current view. * @return {string} The title text for the arrow drop button. * @private */ computeArrowDropTitle_: function(view) { return view == media_router.MediaRouterView.CAST_MODE_LIST ? this.i18n('viewDeviceListButtonTitle') : this.i18n('viewCastModeListButtonTitle'); }, /** * @param {?media_router.MediaRouterView} view The current view. * @return {boolean} Whether or not the back button should be shown. * @private */ computeBackButtonShown_: function(view) { return view == media_router.MediaRouterView.ROUTE_DETAILS || view == media_router.MediaRouterView.FILTER; }, /** * Returns whether given string is undefined, null, empty, or whitespace only. * @param {?string} str String to be tested. * @return {boolean} |true| if the string is undefined, null, empty, or * whitespace. * @private */ isEmptyOrWhitespace_: function(str) { return str === undefined || str === null || (/^\s*$/).test(str); }, /** * Handles a click on the back button by firing a back-click event. * * @private */ onBackButtonClick_: function() { this.fire('back-click'); }, /** * Handles a click on the close button by firing a close-button-click event. * * @private */ onCloseButtonClick_: function() { this.fire('close-dialog', { pressEscToClose: false, }); }, /** * Handles a click on the arrow button by firing an arrow-click event. * * @private */ onHeaderOrArrowClick_: function() { if (this.view == media_router.MediaRouterView.SINK_LIST || this.view == media_router.MediaRouterView.CAST_MODE_LIST) { this.fire('header-or-arrow-click'); } }, /** * Updates header height to accomodate email text. This is called on changes * to |showEmail| and will return early if the value has not changed. * * @param {boolean} newValue The new value of |showEmail|. * @param {boolean} oldValue The previous value of |showEmail|. * @private */ maybeChangeHeaderHeight_: function(newValue, oldValue) { if (oldValue == newValue) return; // Ensures conditional templates are stamped. this.async(function() { var currentHeight = this.offsetHeight; this.$$('#header').style.height = this.showEmail && !this.isEmptyOrWhitespace_(this.userEmail) ? this.headerWithEmailHeight_ + 'px' : this.headerWithoutEmailHeight_ + 'px'; // Only fire if height actually changed. if (currentHeight != this.offsetHeight) { this.fire('header-height-changed'); } }); }, /** * Updates the cursor style for the header text when the view changes. When * the drop arrow is also shown, the header text is also clickable. * * @param {?media_router.MediaRouterView} view The current view. * @private */ updateHeaderCursorStyle_: function(view) { this.$$('#header-text').style.cursor = view == media_router.MediaRouterView.SINK_LIST || view == media_router.MediaRouterView.CAST_MODE_LIST ? 'pointer' : 'auto'; }, }); /* Copyright 2016 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. */ .highlight { font-weight: bold; } // Copyright 2016 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 Polymer element displays text that needs sections of it highlighted. // This is useful, for example, for displaying which portions of a string were // matched by some filter text. Polymer({ is: 'media-router-search-highlighter', properties: { /** * The text that this element should display, split it into highlighted and * normal text. The displayed text will alternate between plainText and * highlightedText. * * Example: You have a sink with the name 'living room'. * When your seach text is 'living', the resulting arrays will be: * plainText: [null, ' room'], highlightedText: ['living', null] * * When your search text is 'room', the resulting arrays will be: * plainText: ['living ', null], highlightedText: [null, 'room'] * * null corresponds to an empty string when the arrays are being combined. * So both examples reproduce the text 'living room', but with different * words highlighted. * @type {{highlightedText: !Array, * plainText: !Array}|undefined} */ data: { type: Object, observer: 'dataChanged_', }, /** * The text that this element is displaying as a plain string. The primary * purpose for this property is to make getting this element's textContent * easy for testing. * @type {string|undefined} */ text: { type: String, readOnly: true, notify: false, }, }, /** * Update the element text if |data| changes. * * The order the strings are combined is plainText[0] highlightedText[0] * plainText[1] highlightedText[1] etc. * * @param {{highlightedText: !Array, plainText: !Array}} * data * Parameters in |data|: * highlightedText - Array of strings that should be displayed highlighted. * plainText - Array of strings that should be displayed normally. */ dataChanged_: function(data) { if (!data || !data.highlightedText || !data.plainText) { return; } var text = ''; for (var i = 0; i < data.highlightedText.length; ++i) { if (data.plainText[i]) { text += HTMLEscape(/** @type {!string} */ (data.plainText[i])); } if (data.highlightedText[i]) { text += '' + HTMLEscape(/** @type {!string} */ (data.highlightedText[i])) + ''; } } this.$.text.innerHTML = text; this._setText(this.$.text.textContent); }, }); /* Copyright 2017 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. */ #button-holder { float: left; } #current-time { left: 20px; position: absolute; } #duration { position: absolute; right: 20px; } :host-context([dir='rtl']) #play-pause-volume-hangouts-controls { transform: scaleX(-1); } :host-context([dir='rtl']) #route-play-pause-button { transform: scaleX(-1); } :host-context([dir='rtl']) #route-volume-slider { transform: scaleX(-1); } #media-controls { font-size: 1.25em; margin: 0 8px; } #play-pause-volume-hangouts-controls { display: block; margin-top: 13px; overflow: hidden; } #route-description { margin: 15px 8px 3px 8px; width: 90%; } #route-time-controls { display: block; margin-top: 3px; overflow: hidden; } #route-time-slider { --paper-slider-knob-color: rgb(16, 16, 16); --paper-slider-active-color: rgb(16, 16, 16); --paper-slider-pin-color: rgb(16, 16, 16); width: 100%; } #route-title { color: rgb(125, 125, 125); margin: 3px 8px; } #route-volume-slider { --paper-slider-knob-color: rgb(16, 16, 16); --paper-slider-active-color: rgb(33, 150, 243); --paper-slider-pin-color: rgb(16, 16, 16); width: 100%; } #timeline { font-size: 0.75em; } #volume-holder { display: block; overflow: hidden; padding: 0.3em 0; } paper-checkbox { --paper-checkbox-checked-color: #1976D2; } #hangouts-local-present-controls { cursor: pointer; display: inline-block; float: right; padding-top: 10.5px; white-space: nowrap; } #hangouts-local-present-checkbox { --paper-checkbox-vertical-align: top; }; #hangouts-local-present-checkbox-subtitle { display: block; font-size: 0.8em; margin-top: 2px; width: 249px; } #mirroring-fullscreen-video-controls { display: inline-block; font-size: 0.8em; margin: 15px 8px 3px 8px; vertical-align: middle; white-space: nowrap; } #mirroring-fullscreen-video-dropdown { width: auto; } // Copyright 2017 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 Polymer element shows media controls for a route that is currently cast * to a device. * @implements {RouteControlsInterface} */ Polymer({ is: 'route-controls', properties: { /** * Set of possible options for playing fullscreen videos when mirroring. * @private {!Object} */ FullscreenVideoOption_: { type: Object, value: { // Play on remote screen only. REMOTE_SCREEN: 'remote_screen', // Play on both remote and local screens. BOTH_SCREENS: 'both_screens' } }, /** * The current time displayed in seconds, before formatting. * @private {number} */ displayedCurrentTime_: { type: Number, value: 0, }, /** * The volume shown in the volume control, between 0 and 1. * @private {number} */ displayedVolume_: { type: Number, value: 0, }, /** * True if the Hangouts route is currently using local present mode. * Valid for Hangouts routes only. * @private {boolean} */ hangoutsLocalPresent_: { type: Boolean, value: false, }, /** * The timestamp for when the initial media status was loaded. * @private {number} */ initialLoadTime_: { type: Number, value: 0, }, /** * Set to true when the user is dragging the seek bar. Updates for the * current time from the browser will be ignored when set to true. * @private {boolean} */ isSeeking_: { type: Boolean, value: false, }, /** * Set to true when the user is dragging the volume bar. Volume updates from * the browser will be ignored when set to true. * @private {boolean} */ isVolumeChanging_: { type: Boolean, value: false, }, /** * The timestamp for when the controller last submitted a seek request. * @private {number} */ lastSeekByUser_: { type: Number, value: 0, }, /** * The timestamp for when |routeStatus| was last updated. * @private {number} */ lastStatusUpdate_: { type: Number, value: 0, }, /** * The timestamp for when the controller last submitted a volume change * request. * @private {boolean} */ lastVolumeChangeByUser_: { type: Number, value: 0, }, /** * Keep in sync with media remoting individual user setting. * @private {boolean} */ mediaRemotingEnabled_: { type: Boolean, value: true, }, /** * The route currently associated with this controller. * @type {?media_router.Route|undefined} */ route: { type: Object, observer: 'onRouteUpdated_', }, /** * The route description to display. Uses the media route description if * none is provided by the media route status object. * @private {string} */ routeDescription_: { type: String, value: '', }, /** * The timestamp for when the route details view was opened. * @type {number} */ routeDetailsOpenTime: { type: Number, value: 0, }, /** * The status of the media route shown. * @type {!media_router.RouteStatus} */ routeStatus: { type: Object, observer: 'onRouteStatusChange_', value: new media_router.RouteStatus(), }, /** * The ID of the timer currently set to increment the current time of the * media, or 0 if the current time is not being incremented. * @private {number} */ timeIncrementsTimeoutId_: { type: Number, value: 0, }, }, behaviors: [ I18nBehavior, ], /** * Called by Polymer when the element loads. Registers the element to be * notified of route status updates. */ ready: function() { media_router.ui.setRouteControls( /** @type {RouteControlsInterface} */ (this)); }, /** * Current time can be incremented if the media is playing, and either the * duration is 0 or current time is less than the duration. * @return {boolean} * @private */ canIncrementCurrentTime_: function() { return !this.isSeeking_ && this.routeStatus.playState === media_router.PlayState.PLAYING && (this.routeStatus.duration === 0 || this.displayedCurrentTime_ < this.routeStatus.duration); }, /** * Creates an accessibility label for the element showing the media's current * time. * @param {number} displayedCurrentTime * @return {string} * @private */ getCurrentTimeLabel_: function(displayedCurrentTime) { return `${ this.i18n('currentTimeLabel') } ${this.getFormattedTime_(displayedCurrentTime)}`; }, /** * Creates an accessibility label for the element showing the media's * duration. * @param {number} duration * @return {string} * @private */ getDurationLabel_: function(duration) { return `${this.i18n('durationLabel')} ${this.getFormattedTime_(duration)}`; }, /** * Converts a number representing an interval of seconds to a string with * HH:MM:SS format. * @param {number} timeInSec Must be non-negative. Intervals longer than 100 * hours get truncated silently. * @return {string} * @private */ getFormattedTime_: function(timeInSec) { if (timeInSec < 0) { return ''; } var hours = Math.floor(timeInSec / 3600); var minutes = Math.floor(timeInSec / 60) % 60; var seconds = Math.floor(timeInSec) % 60; // Show the hours only if it is nonzero. return (hours ? ('0' + hours).substr(-2) + ':' : '') + ('0' + minutes).substr(-2) + ':' + ('0' + seconds).substr(-2); }, /** * @param {!media_router.RouteStatus} routeStatus * @return {string} The value for the icon attribute of the mute/unmute * button. * @private */ getMuteUnmuteIcon_: function(routeStatus) { return routeStatus.isMuted ? 'av:volume-off' : 'av:volume-up'; }, /** * @param {!media_router.RouteStatus} routeStatus * @return {string} Localized title for the mute/unmute button. * @private */ getMuteUnmuteTitle_: function(routeStatus) { return routeStatus.isMuted ? this.i18n('unmuteTitle') : this.i18n('muteTitle'); }, /** * @param {!media_router.RouteStatus} routeStatus * @return {string}The value for the icon attribute of the play/pause button. * @private */ getPlayPauseIcon_: function(routeStatus) { return routeStatus.playState === media_router.PlayState.PAUSED ? 'av:play-arrow' : 'av:pause'; }, /** * @param {!media_router.RouteStatus} routeStatus * @return {string} Localized title for the play/pause button. * @private */ getPlayPauseTitle_: function(routeStatus) { return routeStatus.playState === media_router.PlayState.PAUSED ? this.i18n('playTitle') : this.i18n('pauseTitle'); }, /** * @return {string} Text representing the current position on the seek slider. * @private */ getTimeSliderValueText_: function(displayedCurrentTime) { if (!this.routeStatus) { return ''; } return `${ this.getFormattedTime_(displayedCurrentTime) } / ${this.getFormattedTime_(this.routeStatus.duration)}`; }, /** * @param {number} volume * @return {string} The volume as a percentage. * @private */ getVolumeSliderValueText_: function(volume) { return String(Math.round(volume * 100)) + '%'; }, /** * Checks whether the media is still playing, and if so, sends a media status * update incrementing the current time and schedules another call for a * second later. * @private */ maybeIncrementCurrentTime_: function() { if (this.canIncrementCurrentTime_()) { var updatedCurrentTime = this.routeStatus.currentTime + Math.floor((Date.now() - this.lastStatusUpdate_) / 1000); this.displayedCurrentTime_ = this.routeStatus.duration === 0 ? updatedCurrentTime : Math.min(updatedCurrentTime, this.routeStatus.duration); if (this.routeStatus.duration === 0 || this.displayedCurrentTime_ < this.routeStatus.duration) { this.timeIncrementsTimeoutId_ = setTimeout(() => this.maybeIncrementCurrentTime_(), 1000); } } else { this.timeIncrementsTimeoutId_ = 0; } }, /** * Called when the "smooth motion" box for Hangouts is changed by the user. * @param {!{target: !PaperCheckboxElement}} e * @private */ onHangoutsLocalPresentChange_: function(e) { media_router.browserApi.setHangoutsLocalPresent(e.target.checked); }, /** * Called when the user toggles the mute status of the media. Sends a mute or * unmute command to the browser. * @private */ onMuteUnmute_: function() { media_router.browserApi.setCurrentMediaMute(!this.routeStatus.isMuted); }, /** * Called when the user toggles between playing and pausing the media. Sends a * play or pause command to the browser. * @private */ onPlayPause_: function() { if (this.routeStatus.playState === media_router.PlayState.PAUSED) { media_router.browserApi.playCurrentMedia(); } else { media_router.browserApi.pauseCurrentMedia(); } }, /** * Updates seek and volume bars if the user is not currently dragging on * them. * @param {!media_router.RouteStatus} newRouteStatus * @private */ onRouteStatusChange_: function(newRouteStatus) { this.lastStatusUpdate_ = Date.now(); if (this.shouldAcceptCurrentTimeUpdates_()) { this.displayedCurrentTime_ = newRouteStatus.currentTime; } if (this.shouldAcceptVolumeUpdates_()) { this.displayedVolume_ = Math.round(newRouteStatus.volume * 100) / 100; } if (newRouteStatus.description !== '') { this.routeDescription_ = newRouteStatus.description; } if (!this.initialLoadTime_) { this.initialLoadTime_ = Date.now(); media_router.browserApi.reportWebUIRouteControllerLoaded( this.initialLoadTime_ - this.routeDetailsOpenTime); } this.stopIncrementingCurrentTime_(); if (this.canIncrementCurrentTime_()) { this.timeIncrementsTimeoutId_ = setTimeout(() => this.maybeIncrementCurrentTime_(), 1000); } this.hangoutsLocalPresent_ = !!newRouteStatus.hangoutsExtraData && newRouteStatus.hangoutsExtraData.localPresent; if (newRouteStatus.mirroringExtraData) { // Manually update the selected value on the // mirroring-fullscreen-video-dropdown dropbox. // TODO(imcheng): Avoid doing this by wrapping the dropbox in a Polymer // template, or introduce to the Polymer library. this.$['mirroring-fullscreen-video-dropdown'].value = newRouteStatus.mirroringExtraData.mediaRemotingEnabled ? this.FullscreenVideoOption_.REMOTE_SCREEN : this.FullscreenVideoOption_.BOTH_SCREENS; } }, /** * Called when the route is updated. Updates the description shown if it has * not been provided by status updates. * @param {?media_router.Route} route * @private */ onRouteUpdated_: function(route) { if (!route) { this.stopIncrementingCurrentTime_(); } if (route && this.routeStatus.description === '') { this.routeDescription_ = route.description; } }, /** * Called when the user clicks on or stops dragging the seek bar. * @param {!Event} e * @private */ onSeekComplete_: function(e) { this.stopIncrementingCurrentTime_(); this.displayedCurrentTime_ = e.target.value; media_router.browserApi.seekCurrentMedia(this.displayedCurrentTime_); this.isSeeking_ = false; this.lastSeekByUser_ = Date.now(); }, /** * Called while the user is dragging the seek bar. * @param {!Event} e * @private */ onSeekByDragging_: function(e) { this.isSeeking_ = true; var target = /** @type {{immediateValue: number}} */ (e.target); this.displayedCurrentTime_ = target.immediateValue; }, /** * Called when the user clicks on or stops dragging the volume bar. * @param {!Event} e * @private */ onVolumeChangeComplete_: function(e) { this.displayedVolume_ = e.target.value; media_router.browserApi.setCurrentMediaVolume(this.displayedVolume_); this.isVolumeChanging_ = false; this.lastVolumeChangeByUser_ = Date.now(); }, /** * Called while the user is dragging the volume bar. * @param {!Event} e * @private */ onVolumeChangeByDragging_: function(e) { /** @const */ var currentTime = Date.now(); // We limit the frequency of volume change requests during dragging to // limit the number of Mojo calls to the component extension. if (currentTime - this.lastVolumeChangeByUser_ < 300) { return; } this.lastVolumeChangeByUser_ = currentTime; this.isVolumeChanging_ = true; var target = /** @type {{immediateValue: number}} */ (e.target); this.displayedVolume_ = target.immediateValue; media_router.browserApi.setCurrentMediaVolume(this.displayedVolume_); }, /** * Called when the value on the mirroring-fullscreen-video-dropdown dropdown * menu changes. * @param {!Event} e * @private */ onFullscreenVideoDropdownChange_: function(e) { /** @const */ var dropdownValue = this.$['mirroring-fullscreen-video-dropdown'].value; media_router.browserApi.setMediaRemotingEnabled( dropdownValue == this.FullscreenVideoOption_.REMOTE_SCREEN); }, /** * Resets the route controls. Called when the route details view is closed. */ reset: function() { this.routeStatus = new media_router.RouteStatus(); media_router.ui.setRouteControls(null); }, /** * @return {boolean} Whether external current time updates should be reflected * on the seek slider. * @private */ shouldAcceptCurrentTimeUpdates_: function() { // Ignore external updates immediately after internal updates, because it's // likely to just be internal updates coming back from the device, and could // make the slider knob jump around. return !this.isSeeking_ && Date.now() - this.lastSeekByUser_ > 1000; }, /** * @return {boolean} Whether external volume updates should be reflected on * the volume slider. * @private */ shouldAcceptVolumeUpdates_: function() { // Ignore external updates immediately after internal updates, because it's // likely to just be internal updates coming back from the device, and could // make the slider knob jump around. return !this.isVolumeChanging_ && Date.now() - this.lastVolumeChangeByUser_ > 1000; }, /** * If it is currently incrementing the current time shown, then stops doing * so. * @private */ stopIncrementingCurrentTime_: function() { if (this.timeIncrementsTimeoutId_) { clearTimeout(this.timeIncrementsTimeoutId_); this.timeIncrementsTimeoutId_ = 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. */ #route-action-buttons { @apply(--layout-horizontal); @apply(--layout-end-justified); margin: 0 10px; padding: 0; white-space: nowrap; } .route-button { background-color: white; line-height: 12px; margin: 12px 0; text-align: end; } #route-description { -webkit-padding-end: var(--dialog-padding-end); -webkit-padding-start: 44px; font-size: 1.2em; line-height: 1.5em; margin-top: 16px; } // 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 Polymer element shows information from media that is currently cast // to a device. Polymer({ is: 'route-details', properties: { /** * Description of the current casting activity, e.g. "Casting YouTube". * @private {string|undefined} */ routeDescription_: { type: String, }, /** * Whether the external container will accept change-route-source-click * events. * @private {boolean} */ changeRouteSourceAvailable_: { type: Boolean, computed: 'computeChangeRouteSourceAvailable_(route, sink,' + 'isAnySinkCurrentlyLaunching, shownCastModeValue)', }, /** * Whether a sink is currently launching in the container. * @type {boolean} */ isAnySinkCurrentlyLaunching: { type: Boolean, value: false, }, /** * The timestamp for when the route details view was opened. We initialize * the value in a function so that the value is set when the element is * loaded, rather than at page load. * @private {number} */ openTime_: { type: Number, value: function() { return Date.now(); }, }, /** * The route to show. * @type {?media_router.Route|undefined} */ route: { type: Object, observer: 'onRouteChange_', }, /** * The cast mode shown to the user. Initially set to auto mode. (See * media_router.CastMode documentation for details on auto mode.) * @type {number} */ shownCastModeValue: { type: Number, value: media_router.AUTO_CAST_MODE.type, }, /** * Sink associated with |route|. * @type {?media_router.Sink} */ sink: { type: Object, value: null, }, }, behaviors: [ I18nBehavior, ], /** * Fires a close-route event. This is called when the button to close * the current route is clicked. * * @private */ closeRoute_: function() { this.fire('close-route', {route: this.route}); }, /** * @param {?media_router.Route|undefined} route * @param {boolean} changeRouteSourceAvailable * @return {boolean} Whether to show the button that allows casting to the * current route or the current route's sink. */ computeCastButtonHidden_: function(route, changeRouteSourceAvailable) { return !((route && route.canJoin) || changeRouteSourceAvailable); }, /** * @param {?media_router.Route|undefined} route The current route for the * route details view. * @param {?media_router.Sink|undefined} sink Sink associated with |route|. * @param {boolean} isAnySinkCurrentlyLaunching Whether a sink is launching * now. * @param {number} shownCastModeValue Currently selected cast mode value or * AUTO if no value has been explicitly selected. * @return {boolean} Whether the change route source function should be * available when displaying |currentRoute| in the route details view. * Changing the route source should not be available when the currently * selected source that would be cast is the same as the route's current * source. * @private */ computeChangeRouteSourceAvailable_: function( route, sink, isAnySinkCurrentlyLaunching, shownCastModeValue) { if (isAnySinkCurrentlyLaunching || !route || !sink) { return false; } if (!route.currentCastMode) { return true; } var selectedCastMode = this.computeSelectedCastMode_(shownCastModeValue, sink); return (selectedCastMode != 0) && (selectedCastMode != route.currentCastMode); }, /** * @param {number} castMode User selected cast mode or AUTO. * @param {?media_router.Sink} sink Sink to which we will cast. * @return {number} The selected cast mode when |castMode| is selected in the * dialog and casting to |sink|. Returning 0 means there is no cast mode * available to |sink| and therefore the start-casting-to-route button * will not be shown. */ computeSelectedCastMode_: function(castMode, sink) { // |sink| can be null when there is a local route, which is shown in the // dialog, but the sink to which it is connected isn't in the current set of // sinks known to the dialog. This can happen, for example, with DIAL // devices. A route is created to a DIAL device, but opening the dialog on // a tab that only supports mirroring will not show the DIAL device. The // route will be shown in route details if it is the only local route, so // you arrive at this function with a null |sink|. if (!sink) { return 0; } if (castMode == media_router.CastModeType.AUTO) { return sink.castModes & -sink.castModes; } return castMode & sink.castModes; }, /** * Called when the route details view is closed. Resets route-controls. */ onClosed: function() { if (this.$$('route-controls')) { this.$$('route-controls').reset(); } }, /** * Called when the route details view is opened. */ onOpened: function() { if (this.$$('route-controls')) { media_router.ui.setRouteControls( /** @type {RouteControlsInterface} */ (this.$$('route-controls'))); } }, /** * Updates |routeDescription_| for the default view. * @param {?media_router.Route} route * @private */ onRouteChange_: function(route) { this.routeDescription_ = route ? route.description : ''; }, /** * @param {?media_router.Route} route * @return {boolean} Whether the WebUI route controller should be shown * instead of the default route description element. * @private */ shouldShowWebUiControls_: function(route) { return route && route.supportsWebUiController; }, /** * Fires a join-route-click event if the current route is joinable, otherwise * it fires a change-route-source-click event, which changes the source of the * current route. This may cause the current route to be closed and a new * route to be started. This is called when the button to start casting to the * current route is clicked. * * @private */ startCastingToRoute_: function() { if (this.route.canJoin) { this.fire('join-route-click', {route: this.route}); } else { this.fire('change-route-source-click', { route: this.route, selectedCastMode: this.computeSelectedCastMode_(this.shownCastModeValue, this.sink) }); } }, }); // Copyright 2016 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 class holds state that is relevant to the search process from the UI's * perspective. It primarily handles the spinner logic while waiting for a * search to complete. The spinner first needs to start on the pseudo sink, but * when a real sink arrives to replace it the spinner should transfer to the * real sink. * * Additionally, this class provides a method for * onCreateRouteResponseReceived() that maps the pseudo sink ID that started the * search to the real sink that was produced by the search. This helps check * whether a received route is valid. * * @param {!media_router.Sink} pseudoSink Pseudo sink that started the search. * @constructor */ var PseudoSinkSearchState = function(pseudoSink) { /** * Pseudo sink that started the search. * @private {!media_router.Sink} */ this.pseudoSink_ = pseudoSink; /** * The ID of the sink that is found by search. * @private {string} */ this.realSinkId_ = ''; /** * Whether we have received a sink in the sink list with ID |realSinkId_|. * @private {boolean} */ this.hasRealSink_ = false; }; /** * Record the real sink ID returned from the Media Router. * @param {string} sinkId Real sink ID that is the result of the search. */ PseudoSinkSearchState.prototype.receiveSinkResponse = function(sinkId) { this.realSinkId_ = sinkId; }; /** * Checks whether we have a sink in |sinkList| that is our search result then * computes the value for |currentLaunchingSinkId_| based on the state of the * search. It should be the pseudo sink ID until the real sink arrives, then the * real sink ID. * @param {!Array} sinkList List of all sinks to check. * @return {string} New value for |currentLaunchingSinkId_|. */ PseudoSinkSearchState.prototype.checkForRealSink = function(sinkList) { if (!this.hasRealSink_) { this.hasRealSink_ = !!this.realSinkId_ && sinkList.some(function(sink) { return (sink.id == this.realSinkId_); }, this); return !this.hasRealSink_ ? this.pseudoSink_.id : this.realSinkId_; } return this.realSinkId_; }; /** * Returns the pseudo sink for the current search. This is used to enforce * freezing its name in filter view and displaying it in the sink list view. * @return {!media_router.Sink} */ PseudoSinkSearchState.prototype.getPseudoSink = function() { return this.pseudoSink_; }; Google Cast /* Copyright 2016 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, extensionview, html { border: 0; height: 100%; margin: 0; padding: 0; width: 100%; } extensionview { overflow: hidden; position: absolute; } // Copyright 2016 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. window.addEventListener('load', function init() { var extensionView = document.querySelector('extensionview'); /** * @param {string} str * @return {!Array} */ var splitUrlOnHash = function(str) { str = str || ''; var pos = str.indexOf('#'); return (pos !== -1) ? [str.substr(0, pos), str.substr(pos + 1)] : [str, '']; }; new MutationObserver(function() { var newHash = splitUrlOnHash(extensionView.getAttribute('src'))[1]; var oldHash = window.location.hash.substr(1); if (newHash !== oldHash) { window.location.hash = newHash; } }).observe(extensionView, {attributes: true}); window.addEventListener('hashchange', function() { var newHash = window.location.hash.substr(1); var extensionViewSrcParts = splitUrlOnHash(extensionView.getAttribute('src')); if (newHash !== extensionViewSrcParts[1]) { extensionView.load(extensionViewSrcParts[0] + '#' + newHash); } }); extensionView.load( 'chrome-extension://' + loadTimeData.getString('extensionId') + '/cast_setup/index.html#' + window.location.hash.substr(1) || 'devices'); }); @@ (@F  (n@ ( P (Y(@  ۶yΆ$̀ـٳfٳ٦Yٌ ̀ ٙ3̌̌3   ٙ ٌ ٙ ٙ        Հ ժ3Pacbbbcc⹀bؓ'cՂbۏbmbbbbbbbbخbXbb݊ bҌ#Ђ|π ӎ*٤V῎Ń<  2x1~}uܫbأSՙ?ю*΄ }̀ϊ%ԚGگtǤȍH 2Ѝ$1ۑ(ݔ()ߓ%щχ΃ ~ ͂Ћ(ԙF٭mտ̔O  2x1~ׁ݆څ ΁΅Љ"ю-ԗ>֢U۰tӻ̔R  2ˇ$1ב*ܓ-2ߛ9ԘA՞N٧aܲx࿐̩ˑQ   2ձx1廁ĊƏƟίܿȈM  21װwC   21Мh5 21֮ƀP*  21ط̐d;   21ذˎhD&  21ۼңɆeE)  21ۼլϘǂlT<'  2Ь1߱КʏƄwdO:+   2S1TSVRIC=6*  22  32 32  2ݟ-}  ~ -ޜ!!f  fے$#B""#"!!  A +&P##$$$""""N$ا'ޥ&6(R'a'b'a%a%a%a%a%a%a%a%a%a%a%a%a"a"a"a"a a a a a a a a a a aaaaaaaaaaaaaaaaaaaaaaaabaR١6ߪ ۶$ժ@ 636666666666666((((((((((((666666(((((((((((((((((((۞$ס(ժ+ ( @ ()ڢK) ̟((((Ɔ( f((((߿( PȶݤH ΦĂ mڶ PAܜ/ ٻs }ڮ PĞڈS ɗϕ Nٖ'ۉŽ  Nͬۙ.؄ݧT˕ݎC ޷rNpۢJ׉ وݥO<܋Ǖ ՀNڋ ژ/Ểۉޠ8ņ ⺀Nʎˢѫېٌ ͥ= NҮڕ#׆uʕ NŎےׅ߭b/ NЪߧOׇ ׉qb ѱN۶řݳtԗ<΃zѐ2ÛNj yNق| ΂Ғ7ݸ͙ ՝KNW٨cݷϳʐ  Nm NӦ= NҟQ N׵̔a7 P P P P(0 ;YYΩYׁYԲYYYdYߒYYYY(YY Y Y YYYY<HΣa) IݤMݗ#@9 |ւ佁ߔo۹ {zܺګ^ׄިRÃ܍ָɈ {Ӆ{؏wݒߧIF {ŗ{ۯߤDً׶ {{ߦJׄǚL {{Ǘړ#׈ ƘΓ {ཇ{Ljڨ]ъ#}ԘBսٷ  {~{ߍҐ1٧`˪ڻ! {{Ϛ {{[  {{ҡ[ {K{N<%  {  "L !  J->(Y(Y(Y%Y%Y%Y"Y"Y Y Y YYYYYYYYYYޣ=(  .ۙ.ظ@͞c .*=c ٚ3n'; ڝ=.iYa ̧‹۔$p dٓ$ ٤VNҏ.٦[0 ߻Ԫ3 ~ ~P   %0$" /PNG  IHDRw= pHYs  tEXtSoftwareAdobe ImageReadyqe<IDATxb?-d[ K!xbAH-7s)5 ~ T )] Yd8X-Pb}%Ĩ!8 pAP) '*bXÉh0k801 l4iLiUTЮцgկ IENDB`PNG  IHDRw= pHYs  tEXtSoftwareAdobe ImageReadyqe<IDATx E C &; p 11Q# ?hSJk퀊hR0RX(z@s4g fΝ 6R)ďE4$5M9%$T ĠϘdE0|q=- WHh\\+b^n.ђvShkIENDB`RIFFzpWEBPVP8Lmp/ÕHlI$(=(:"??ٽQGd"CdIz2 !€~\ *x.QFC[mS-~6xsڰxM9N|&3AR@/oN|?^=k~8}qՙv䀹sz G׌.U1iAi,IRF0rkEk .sIPd&moؙZtKQNEQZe:$IR\|y)aHH ÆZ܁O@?Q@ ئI)a!Q#Q)"c٨/\Ù@@.NH]RJU-}ry`  p @DS~Y<6YJL8 6l6-i. |?RG:P |Q&袿}i:}oz:/KLtVi=i*6[Yxٖؔz壴NҦmZ;n&!mHi 6@<.~:6,cE_CαSVu6 b7ը : FW7`ضOF?QznՁɆ?̿sPˢAt11\m6Irٿ׈Q)eș&x! 3vl]}E& (OMHRd1fβyߟfpǬ)@`7Y܍Ӱ95>ԋ l6mQjH=[h'87  5> $~2Cm&mϟ3Im8Ohz?$I"=xٷ?l1`%M.$fVa@@Xck {( m#Adqm$=_bvI$Y*YU"^gaw~.ց|EI! y:u0A۶iH{$IuoL}m:֫Nlk]1#%I?l:nɽT>p{3_><8lxd,z&?OjB\2Y/Mkwek)ё8/?ux:ܣ]>t|sl]x,Qxz%q;?;&|~ 7ڮ-EcQ{O D]5!/uʱԎJ\KyC$#CmyM{^zaڂx:g?q੡OM IuжsVch2"j!?Dy2$/󍏧|~>粫,f2B'$hn_ԩcGh ` P+A;&G[[mS("&O~ n|~kmӑfHnɳPہO=+BRWĥwaՋ"G)aEO]ϭE FPG$ς<O3H긶 !jq8I~?g[O ___d_YHoA?i_|i pR/rr/%?}p}vO#?|4s[ϪP$-'m.CG=?{{s>>؉P\oYZ=q~oecǶ%o|:{ZNlی0k>-q3 {0ϙ:8=y6m-B.f~l^5ɧ|m#f37g 9n<7_ׂ|¨SY6aホDHi1c[6.̩ȃwlch'gǁ7FuB$gfB$g2چ} r`N)2c3 ø8w^kS` [;^(3uvOccg BiI֐l:tmP4xZ=0($(IL^6~$Ԡ!S{kLLM?Ύs` JR*kذ7ym̤5ϝo9a D.U+Fg/ZYXia c̼TʣZCk:̾jeFk4f>WK*W<4y#3l[sb|zdmlKGǝcư/]J'b_ΗhZJOaힻ뤫1㹷p))ǵ̷fbB3M{+O3;ی3ØBU6;f13Y|,RŭBl.8sa`H aaf (^n[ y1pv؎amf^RT|3:g{J>81jΰ59"Q%Re? rDu;]n%,8cck^YBbgs%ʳ%mmGjƛqIX9eH^UBg̶v60.'z!B"zis5bƚf0K 9 Y͐8͸svM64a !5d -=qqr0Bc,glqe9̣7Dt޹mG۬- 3br99YB{,wί1]Ȏ:/vکף;͉.$O׉=}kG|j9/?hru`?~>y/!.`H 4ʰ|Q΄ . ,}@ HAq !a1&/C6N4^,@ fBt "zM>[V+JzY8K3w? + KsCw@gi=tC5>K93Q?tdEy==+6Nѵܩmims泵Zf,n1'L&~').\n%X>GG:-EeumC=:}Xj 'eL1>k - 8['۴v<ڹܬB |V|NL~_b#:1>h>;fsܦ'5iC{n}ގMZ-58TRo>\QPp p\۷>'$ W32# vkU!.;&*CZ:qke t坾rq=O=򱗙GxE vUS Hruݜ-أ qUGz:z%z%v*5 `i~ٙkDx`v( o]8uゥ'*3*Ȝ׵͐xF`hm3e뱇yD-@ 25MAqOCN|4xL^bFػ_5kXi[A4;m f7 ][|m(ؐ< ǦCL;u4)4Hg$IGY0 uʵz`';M6tbaا`PB5lGv;mRl.˔YK)f<bRp﵃ÑuhM]kxp_sZaay$U0ktDŽ~cXzEJCsrU~VWSa9㿙&!H'fv̩cfzy.HL"3֙' ӊȘAGiQdq UęNqx˕= rXc$kȍixOg#XfP14!W`_@'ǍyTOB@az`=`-v< @jCĭ%ɶ4q>d=6c̼~l,030 $M8mKi).}۬jm>{kg*SRY!mmǂVc6#ZN/Q wzi'!ub8<p,FÞ$.z!0O{; Dq d$BL]fB`/|] ^C'x0c%bc(5&fOgu-9szjnr0|)@p(^ }K,c"$tui"|]4D+f HҫL>&SʹN\TeH Z xH( -t*59I(vKEC|:9M 3JTl_DBZ؎8Ma[Y?kZe1Aל/G®@$thfQ mD[ bVxxXzxaAnƌ˕qee4Zn}QQ- A<9&6`jx>PXPnjq()E1+kU :G\>#.ut[c1cL>3=J ZV:*עLC:+J%B7 sQ1ƈ V gDoZ>/|[[yO3Oߟb4q[djǪ)ds0`#QQ2ّcAlj2SǑ;0yoQp݌Qʥ?~+ZSE0Nj6+S݌11o61jieZ_WSk߅f9**W YFfiez0)ՇyleN#ą2Қs;bk>`PT#fܰ9ͽag}hсK(ribɐ5-_n'sy6FYҏz*y|_?PV ʝYM=ڠr_adhFfrf%l޳K̴brq`qRqg:^2wV6=JA1[h]]~>t촥(d \(c3,HQ(@Ĉ#^/Xv eIihm m`i2sT%)Vz:*@٧`hؐF,0*:p?8lke{yCY;\NMc`R*זŐE 3ɴz>op'_ϺGcDCkC%d8dB.1s,e3EnʱkW4mqARdZFkiǮa2kKo=Zg @_ǂh*ǤnP&Ĥo7)YfQx>Ƚ] 3?w8D.u-Ӵֲ=6/ְv`TrRRsm=NC*뷐藶VӦ:"tr)t&2&W]ԧSe0sm*im#tRf6&՛z#irj?ֱ Hr GP$@tf:(EBRH̕,):m[fw ("͞D8^ttߵnwX3GџhZ`vE%E*o8n6xb=6 WqSA )òc5Af30 鋍 ʥW5eA)*y4FB2?j~.=Tlǡnq鑢&Д:SB 531̝K.}36,Z #Azw$z(Wac親Ԡ#P풦yl7ŪgßuKӉ@)/ sMv1#O1s)Klf `yP<5z?ڎoP p[l" $"IY&p .N[|CkE/ #"*n qYPBXޕ.I{\x `/ĎsB=01QPT9Ts!787Ѵ AŅ}V!)%ZBҷ,S9A#=lH#`j0Ea&mT 3L. ǫ趏9˼η! sp\֬yDfIU |UB|,=,x`@g*Յi%~ٷ<'UL2kGAQ8j0/ nD!"c,v 9$ wR@m @<3[X>z + ѴP+_=W4_*ARpH ʙNlcٸSDD+mg蠧ׯG%2aJxU*G@AG90l&M:{g_lv}uy{Nd%,g0'<øؿ꯮=dkr4 hTPΒ1|gkb.͟ٸy1]/_kΙzX?R //k5{ՆiT_aVJ4\!>E?:ho# _V[IypwOvsOq >rlge[Y,d VtmHt4}?0 s+?÷-LcD{ԻSEP>xO,5w|@{>_9KOlغPt,y%Qaz߷3RǬ>iƪ]Vnke6N>G،LfGG}!qOYOc\v֝Wv\XW=n.)dJ~H?sh|f;'}d@yb$bUWvfu`+l`Q Y9whpO'+r ~t_͕hv5[17,5f}\Dmm "/|UX[9Zk4.<@gX}UAXjdQA]{ BH\g ,sK*v yjCyOXV`QTheQ…cbuk?&R+}[W""@lt2MhU (Y"R -:3[ ȍpaِQq.0*NP@²ʚB>9J^O%=·0Ԇi^_O=gZrYO h''^jB-J%hJ{'L@CVt,PWyCC#0깦qi])kzCWX ')>޲/7^%/?%gtzxqq}2у@H(/V.qe~^Jt/"P.@:ZXaaR9@\g٥ӹm=Ԣ5B{L\$K}bJ[B;[56a Ex[8ݝ@\tWO+@YJAw&졈Cq?ĎB]r4w^m)\pFY5ka ^/=]!0)Ke#,j=JG:YNW!U[,~DN5+8Yr@ܫ}(>N]2蚜dP*mdN> $>j ex,͘x6Eb-[(Acۢ6Pk„< iAҌˣ4cfp9S3K"vDѕՅ5ܯ^ķ"3/Dj?anvhFrSjqfH^@e- 5lH.T˞- nqɪ(>fMOtNר.JAUW`gC I\9Q %ǒ]\/J&ytV,Bx(jİ̭uTla1-omPc \__Ք<6XvlMgkuT)2cKIߥċ>z).Cp]uuأ~Zz|s܇453xd7Nn)|T6% l9Spzsk]jy+tJ 7c};WaD45&vĬЇ,}|M>ƈ[,ESetį>Ee d}-yYPͳi^e@趈LAy$jD^ǽ 0KStCA[S* ,Λp1))*#Cu& Ȫ>!s0`6r~̫@&r ?xD>` k':jHp@B#-%da,VKyA [L C֢rFpZF_&{YbN B6j[@,2 &}ۊED泰u9u,D BčZl)gks+I+_?ζ<|%u~<ɿ&wM|?~W7yG?BѶϱs3<鍱39ggy3~vk;`mfcc~thϬGRoA;`ND{4?{89cҶ3~~ӟ3c^w'c8g?Kv^m?Lێ3۷9?K3>%f?K6^y3C?Jǘ|6;Yt.?̛Gwyc~7^m+uߊQ3EP·II,W@T@HA[4@@մ_F3B#fdYk$qS`AcQ*\|L$x @+K$6F1߈ɆYhLD ?@9ԿVJ<(H$+-)kg»-X;\ I 8+ģ 4;tDUlF 5`fvf3;qC4! =D%##Kli7q&sr>J€Pj! lEY@/k6(m$+W^>T1E#|, $p(g140 X &,ffq!㈶5i i Xq *@7&dOh X @H623!f@\j+ЁdVuu!,:q UrF)X[QBڂ@ӡ0iXX1 '*;<b{>8;# &QJX@OZlg@7ca@̘{{a$fWT; V@ عxP"hyK_6RX7. \[h)gIY0U#63bXjq*ĥ!!ibL"q-H *%KxA 0K@Di4c2>FR׭QñJr`Xb<7e6^#̚ܥiLWf@UL:R4foW?[Pk s'5qϚj a?Шp&4(yog6e c ސ5O>MxSeLs:ڞ&0TI샗*4Nm;\Q6 @e Eaq93 Rf㹩 2d\A\  \%0 Sx[*{w&=c_kILVVN-WZ23R4x sug^NɱX4_kxlB H0-h csZ3N,'xcTzf(82&uvTmRY#M) xvc* O NfI  PFfRْvj w.?v5e),@ޙo H Y=Lٻ) X{U".R@a [@ 4m' ]Z̨f{eLxխnrQ3"(Q-[TPTsv|!\8W]~޲>i<lKQK"幖R/%GO(xr]k4fnZ[l!Dp)ɶ h6 XURW5EŝD5h` A:0~^dR!'r*p _V̓#kQ{Na1\.8릜"] ($b]H<WnQpSؐ. ?sa66KiʠK!S|A91Q Lx)R2Ԙq_=LRD2x HE8cP^JKPJqy`K) Ճ?VJdL$bpbf2_J4 PpZ (OL~ΉTNưѭqn b1QQ d;KY,sR2'fFR/2eH!ed +S]Xb: ī '8L|e2NE8!V^+[DKp _:05|*Fj/cמV[uu`@h?G?͹xsno/|;~>yg_w ebW9Q}XF,Sِ/no|ꮹt{?<\`>(ogpsإܐvsGGo!mu>,?E I72,[>9ԔZ9΍%:]m~96[tj.tgkуȔ0~L9]/.$__5kxϸ>q?/s"%hPe|6٧~Üg>e^g_v0f?'zk)-ߘ9(g`dו؀g/>1a栅 ryn^gt3' ۢf1aN,#w"xas}ΙY20丬im;3\ 嵟h68,h`1P.pY"f1Ì"mq93fd`i98evw^TPLݞ0f}E1J@Ό.%ocHa7F乱$$ c3GTJ.0b?'t0M߾g!hYgf ^ il@lƝ//%mY͘MmA3sT$of09է:MsBa 3f8YBnv"~k-^6 QY/ }C1:h3Vu73S͘o^/e_5ހ 6f&$.(ʛތ&f`Fyg7Z浾٘eƽ\vٰ_ւ9d|lx.Wσyp(Gv׹kiͲ8\\.m^6y\{f-2ح`d;cԵG{i6{:qq]1zp8>2̆{k}c{雰fr#^ca~NX :a][^wgqnbf;cZnCK0ps=h&CC筸\}yE<.]{y_uo 1!k9s1kX"ö1#)WH:c6˥8lfys=wY+^Z'rÞϥϞ|DZq{etҬP.nw-6O GŅqz.{y<*a}c׹`vw-s ׁ86{q]㰡px׹u2sK^_k]u\˶]qUb/{Øy\Eapl[&[9eVr^7{ul#Jp8㘽:ZwkM}*ίw6l?;kzvl3z y= &R^m6GlĴ<T`_r|.uf1[3bNZLDΰ`Ycvci/Wێx}ra0 zߛ=mLc!qoo~l a_udm,Mkp4JrRʗc>s`sdd-F~i(J%a 9lދl61:D򢜭dރN/p.=xݧ#86q5hFhR׽\{B=ǘX&h-$FuܣP?2D\^g8P.חiQ/u!ٚ]S+뺮zl{qvZuS[J L˹6ZC L/(̶ARJ"aưGom6a#,7ݵD5^|NȘ}i^|۲lV{0^_o%J).cϗic˷؇;zol|9uRHJrys]\3llyK$<`o!Izm{8_4暵NL0DGH>lr̷qlZC|}RFƽjW_l(76l>\\\c|xri^kFb؛ۇT}6pOhq(Dy]+}|ss~JfL$Ioӝ.=l}qtE/d /Hq,|AB3|;|b033roqPln$$, `{+b=ci0ow^I coEI0ƌ/}Gx%b^D>Diڧ$QH f[uz_{ a}"t1o狈a0sM;ʌ3mf噗}Ey5m>p0=fy_k#g1e |qoYvD:AMEaؼ%^.UIVKVmc; bu zD s)Xr5Y~X0fX3f߽.\6c!Kr yvXZqwt7sC`d}A>'d9ǶgTHgpQ1q͇ rP˧~EoAp3ی#P&}cOIJs* 3bay>e< -nk-ۮ2.̅NK-?;3qR.^\v$ߞ6]Z+{v?`n͕ٙ9lcc1Uh3$9{Ѹ5scp!yÜFmac{EvK[_{粉}^D'͵>M.#79Ҷټ^x}H:z6%{箙8]'h^on#kl6,\Ksl^ߗ]]28kw=^K~paFrݍ~ cpfOoX/[umRw5p柲>f 9Jѐ([6\_<_m=5=8խ|&99Qt{yvgs1CqG|//Rvr޽"88.zk6{e3^zyF6gnkMlfifvUݺ1De۲ XMnC8s2/$ہ9jbd*2{uE?N'31SP\˜m868^[u/TWh;_&fisAzy{u/3 ɴV\\ύŋ9O|uU3pt åcsc\$bp6tbT.\$%]W6sP(.ы=6>9c^gB_@U#Iw ֮^Dll6AJ}$Mi-W fFHQ>rB_AGD3wG[ޓ_:1`H(_҆Z|&ydy=H>Dϰ(zv^4C Ԟ^)EﵦVb~Îȯ׋| T,JQ[ S@sG^#K7el:掮c}1< ،EDGAVD^=/.|JVy +aH:(?-uenK^dfrZi׃?}߿sKxC =裚lH!@^ A~xScN.KدOݥR=o> ʇGN,<؟[2J ݃,%{"mƟ|\Gs W}yX8Q aLSb'T]eK,9KBO l5^cˊ%?.{P'`6rC&iYX]j5L{a/=z\>Yr]f|c4[Vҵk?,v4=ut`()#Z B{z8><\-0s={M܄6):-.hlDH`IWoqzMōmxJ{B}R{ ?>\rU?F5 T*{i)'F;y!}<.6vo)H.lqm E\>KWHMь!th{< S[v0.[o1"7W?<|tHVMP҇xDq0D HqE"Fa\̏GṘbIAOG5!̶dg(nԃBI,1FssV_}$DCkFni Txnbٛocr1?5y@ĩ!*epkt>nBK :7$2W~Ȅ77 oʢ[[-Ch(*exhCE#AD?+Gם[:l[a&BZ[ϔ4b&c!ne),Pn:"8J_ayk~h&J^f;W23%|Ϊ03~r^2LHPm̗s$Du&ӵVҏs#xBg~ciHW$,߀M1ZlQ`ν}W'o[Q-v㿘gtA(ByxAZGQ;M50km"Y#;UK!7޽quha#!tv-!Z v!eY5"cxd63?dv 8*9͉X~1 Zknu˦F[_ok˲e 4S#1KoKTG\*"k9nv?ug Ed ciUͮl|!ʬ$;6Rrmvn@+wh:ﵪ;py1[!pᑃ?~:{kzjPnJo"9ѱj]! ;3zvf0P[E@b?>6ԵGd@_-!NQe 4=(ܝb()t\Gă;$vB(qz{koۯېX_%ʃjZgAqW^.-tMޢ:8G9a^<ǨO?.<ҿ\z<)cD[s6\\B3cv A],q ^g 7[+;޻awOYM h^0' rG#ٜn[ΰml/4>Z P!M\FN^"K]*5b SU$Zc`G۞h+i-<4&)Q?7;XMhbg^@|%4G\|7;]7%X4pAr?v{\R;h9MtiXY4Fwd-O6iBؒĮUx-ٕPESo|ccZs{sα,òBܵ+!)w$fgBteMwS{% AL[:9"¿5(nո-Sk؛[@WK$A$ѥ) օ hԷqF\p@[vݏ)2,kTh X@ҴвͶm22FΒ`4*3_BR] ltY~1>Zq^ALYF%5Hb^oႽy߇onzϝ[7}$!$[g|1W!APOxB$؂'d&nLu؄1lzT cP|}yCPHgWַ;Җ' (x e3t6+jq'/p{_xIYıG @B 0 hV-@cpkCoW~MnwkaR OHnr@BW@4OX@ ƥ<ͻ9PT!,C2)) [͚sK3.ądC!A௰h b5o]$Qf& |[%NYZIweӸDy< Fa.2oh%[GֵF8cԌwqr B?<ٞ8I`2Q3N%Y\Kp]l@qL ,dvj(0!Q!@'rhJlA@LiCH 7@4CM|T{;vYl4HljwCv¸Ac~'i32QU,XS&d$|& $ja`'.S'̕lنِS)4dguŒ V[H>q`/rV'7Z2&dG#@)L'@r - h$A @@3皂0pA_yYGIZWFtFBHYlM UHMDhÅ4ZB`P5 I@h !rȔ*."x.ǔM~F|;0a=о7@&j$Ḏ%< Pvf>HiܔjHG d%E0vE咠P|@P$iyawAqCHÇBCZ$d@2!`YHnNww<w?W6oyW7Gv8¸@5@HIe0J[6uX%4cEZguoyfx)3Zr` &,XQzpgdPmDݥQ-ǻbrWEG#Aڿr8t3xL.LV@'v,PHlée X|~]HpE\WJ/P^~bߒi6ǥx7[9PVlc1eZo]o˩Z˜RgVƧS^cz+"(gujOgFxm^Ҿ@$?*G5KhfnwL])w êq.k>RJ >#7/ΕĖ،9g`QE|a t59'`m0s(n)վ#@\h2mi*ijFk]`00_eNo(C<$ M ܚ {4[e$i޷cZVjbg>H:i:mНrGNl7qGvOUu%ޖ>^Xhk۟L/S)4tYz L\-&[h{iч1B6{m'USXŲxӷD^ɬʵ2v[suWYf =fFLS3`y;'h;\J8)^`[2A>g{W{HUS$7@rzAfv4¬lTmǗ9Bm`+f0v[ctÛluwKDv;Q(U#_f͕,nObv2bme;rt Meҧ7 XPrm+7mwie򺌌5=lEq 0ZvJpUjbךgsgk;FVvPx1![(v7-Xf?[em#ZZkӵr_#5]ĭ+1ZLQhߍotjh[;'ܘ Y^hSs :{'wA^䪴AXI)5hMtHF; ?ޖ0h9e'AN:{stTut/lQ~ Gvpi(|MIh;ETA8HVv[ @5Ղ*sZtWknW7oVQE,4=!ZYHS%;f{P ܠkZ>_ljL;-l 4w-hщíK7jXҵ/@:)c"3 ҸXgd&H.5ruzSBhq;0❀EsvQ~t?Qf@nL2yjE$сj- n&dDuϳy;5Эyo[4M{tZhNvRTآukaf)$uMXk t1+6tA/8xޘn'zc:r4F8y6њ~<ޒȚ]k >v eym X>F0k+lΚ. S֑p qujmv H0b]g3oPu2`1 2[ܥ0WGLMƺjs^V;s -s쮖mߘ޶me]/_>EHGw (>h, }$_;?28mfgNIE];Go;(/]^>WeV&C6z̧})zrF[AG[Gi# mFNƶ٧~ǜjG摮NK,DbBc9m/d݉LQhh0ܿ+~HH-Dzlg7-&+_hjl`rB x 0Bm6~HvtN,0ǿyYM|}Գ-䗗qG߷u:&yAَ{s7{v%4?SLir<f_DQ^$nou*|yFAscmz &9kPd~ۥ0fn%4j̸qyΗͩC1lǽt|ζ| GeYEr8'J&22MڍӋ&MwAϥAkXX,sNt6sWZoɶԐirZk`-3m[>lwJhy2tӚn9`;%$Sn0jĜZήYd wElgʍQ3e2ڷ|Sy?w'+'vh>'Rr˗C#[c v5Y߯5BAurDQcͭB/`:dDbC|$Zc¾sX̳i7!0WsuXZN3}swӠXtXkv:6)F˶6"C>f/uDI% g̳Y-b?3_!h|0&߄ɬ51H>.EDwS/C;wAZZ.vj>`Y/T4ֱ}0aX,F76kG_툡enNtDGJ Z.[P;@t#0{cre]oFlZҐ|ʐm[\Gk OA,--=TIԧcYk޾|h_2zY/ <׈B:7#]]>Uݙ]'5eA/J<7sӼZ;כݩ"I-L:®׻uou%wٻAB}H,3EG,y,u)kݵx~d5{|Ic-#N/b*}ahhtsa\rsJliٿ%ޛ`鄉ek/1ͲV{dhE~Ѧcn(o  V[5r¼gbњm:-H2fqC#O޴&qʰ3s'YM%&%},Z2ۆG1d/XtB͂e(/Z4Mmy jSL\l3'b,ʹzh\e'sW?~||#sA;]2*).gh;Mnmn!h*saevgr5߉3ϵ{ѦsJ ѹYj/n0sV^]h\ks`B7r i4ƚl5,q8e?1Yhna6c{'\z,Ȍ4ی["ȸ f̘a7D(57'鱭aR\Ѡk`mfXȥVKЉ gB3IѮ%vx f17 -\%Dew,'Ǭi ny0cAD+^/Te0)JKzjl{ 3i!ƭ{4; rܵ Q68=6dfLko Bp&ܟos`\̓ta5̋89{ZGQPsm M[>p[,/mR )'yMZMn)-[#yi4L̯wDُxxj ORIFFWEBPVP8 * X>m0I"!p9 inPZ3g^t{m7>F꺜U4/<֝Jye";۲5y.kR Uua9;fˍ)_ѥëE2/<Z{iXb C~$s [rTHեaJ!ԜD+iNit%cp76Ut 94q:+íIXI Pf*tN|E)Nfu< cvta|N?ьV_+ R5EZz$,5 iK %O4sK\8̄^ ,?XuyX÷K;ۿ74~t|φ]A_;U &=oqx',S{zoɷzrsvjه=1IR;sҚ=נu J|u4%ד,1w{UbƐ 7W$y6Ob[z%BuHآ !qS IAh74|d(&9I_9Pj1*e֗UL ]z{9zG8QN&d86ojb+G1aG9h&I$#k4>uŅi=5´jBa#~ ,GO^,ŴR=23Y$Ĵ`#2G%#&͚Ņڌ e%,QH=OX?EPh|[@܍df,(a#9:M0gEs(;?4x_o|x.K[g%!;+ۧ_o3C:jE.uY1ֻ ~IȪ7]"h٥S?UQ]ywAn&,t_w_N{?:cD\c&KX76B Q1}v ( U؞QeQ[5h2ξ|yKNY]9|7b`9Sx3gB%!a>k\t1:#x&dgb0LJ0 Z{mLx}4{ (ՆqZ> kOQ5(MbШ/u}"od ;!o c? cn\^u^mgc Jz.zD;-Xf'LPk5i:&l/6qR,O@IH \+G1aFY{(b&vnvZ_^jdC-=؇Roej4(Zum3i٠i- kD)hf Z!KC0f Z6ֈRлD%ajSsZG65 ?'q/{PtA҃.pCvoN|pw~loѻqI EՆ$qCd}}9;R@5Ӗ,A3S!(zKg)v=Ȃ,~ 6qK8Vl|c8 E.wHPvy) %ַ68q/hSan WƖѺ/sf̵ZHMbAD?]d ,X1 UGZ;]eMt IT[olςY~{>C'FF a`=+ u&}}?w$M'4q5Ն14!+#$_#gWA̕I>\]?ŅuH2P6V.(4hcGzek.NOu,ƼP}?;CTa><+vAF^EPV Td-dlHm3+| D"!hYA-N_!( #SGSy 9>1ӷtSVWAM¢?ff[ĢQ };(@SZ] e:$#Np3o)tvcf Q$,is$87 u3cdCML7i4o3u.=> `i 3Am[I?-.lgIZvVyn.ZU#(UR"rQ_9wWhQh86m%2M4 d'\;`oCOǿ˝ٟ/W$ʌik-{}Bᥱaxsa<2UCΝB"dӑ4>:~r[TfIT`9H3,B \3$omnt[% ~sw֓6&4VOtC}YfYD)t .k9b0癮x${ޯK)absorQ0I]eɓ"SbvZIAC1zP3~ױlUF׷$ЂI{cH'l?Aܖ>Y-`Cʍ4PlOIP#-E"2T'W07o'm82[nc USP0r"ΐfXsQ1"ժJ9We׬eHftrn"yМRB31E)`inO{T aܽ-#2)h|GNQ>W^OSwWLPQ'+"o]n4 \`pVf<Ҷ'̈́=Cw{ 8jů d~c}W٠ Jݹ s!X#Y%3uedQړƍq墪&ފC^`o=V~&1V"j#eviaR$j+Zm-ޥ0%`ͧ TՓNz˿k?QJ֯0D'qj.] d !f5p'eWd[y*< *І5ˊT((z!(]C"ڋv:4JM mE25-8\k_%Z@fP*5$%\wX( Ur,;ܿH A?;9 LᬣT&f1iS[P7ǚ$v{Hv0LimSU~~f>"x{SL J7J2`CecOy)SeupqT$~O*cy!2y|Tu.i+DiՅeJG#|VUtXɗfހU)2JOKpB-A>!J? :`?? KKᑨ.'&Æ|Kd)w,NH__a\f+갬I__&P2&.[5쮖 {hT ʑ0nbo@\i]f)#z4f \ z 7y \6*)Mn3 ~V~I2""3"8-dKjX`&6()C] ²BB"V>ɻPɻ`Roa|/a@~Zlhs oi'YP%QWkI6FJfoT @/E rjsyT{3?{<vj+4A9j.z LT$Mw<IH@b~)sj5}{1~]G !^ft-) ^FzE>6gA>cn6UHgk3 Ǹրx?.AH/Ri1-!ITe gM웨`ؙeC'NM0Tó Br`)1(iouԧ2O{PPc?2 Uj8/Y@w($bA\2 #6oK0Ϲc? yN`r=q$|Ѯ\5 3sha256/fjZPHewEHTrMDX3I1ecEIeoy3WFxHyGplOLv28kIbtI=5 3sha256/m/nBiLhStttu1YmOz7Y3D2u1iB1dV2CbIfFa3R2YW5M=5 3sha256/8Iuf4xRbVCmCMQTJn3rxlglIO1IOKoyuSUgmXyfaIKs=5 3sha256/k/2eeJTznE32mblA/du19wpVDSIReFX44M8wXa2JY30=5 3sha256/urWd7jMwR6DJgvWhp6xfRHF5b/cba3iG0ggXtTR6AfM=5 3sha256/IJPCDSE5tM9H3nuD5m6RU2i9KDdPXVn4qmC/ULlcZzc=5 3sha256/0Gy8RMdbxHNWR2GQJ62QKDXORYf5JmMmnr1FJFPYpzM=5 3sha256/8tTICtyaxIQrdbYYDdgZhTN0OpM9kYndvoImtw1Ys5E=5 3sha256/F7HIlsaG0bpJW8CzYekRbtFqLVTTGqwvuwPDqnlLct0=5 3sha256/zaV2Aw1A742R1+WpXWvL5atsJbGmeSS6dzZOfe6f1Yw=5 3sha256/UwOkRGMlP0K/mKNJdpQ0sTg2ean9Tje8UTOvFYzt1GE=5 3sha256/w7KUXE4/BAo1YVZdO3mBsrMpu4IQuN0mhUXUI//agVU=5 3sha256/JnPvGqEn36FjHQlBXtG1uWwNtdMj1o2ojR/asqyypNk=5 3sha256/AUSXlKDCf1X30WhWeAWbjToABfBkJrKWPL6KwEi5VH0=5 3sha256/zSyVjjFJMIeXK0ktVTIjewwr6U5OePRqyY/nEXTI4P8=5 3sha256/9dcHlrXN2WV/ehbEdMxMZ8IV4qvGejCtNC5r6nfTviM=5 3sha256/E+0WZLGSIe5nddlVKZ5fYzaNHHCE3hNqi/OWZD3iKgA=5 3sha256/QJ/69CTHYPRa0I3UVlwD6N4MtToxpQ1+0izyGnqEHQo=5 3sha256/LKtpdq9q7F7msGK0w1+b/gKoDHaQcZKTHIf9PTz2u+U=- BadSSL AntivirusBadSSL MITM Software TestF Avast Antivirusavast! Web/Mail Shield Rootavast! Web/Mail ShieldK Bitdefender Antivirus%Bitdefender Personal CA\.Net-Defender Bitdefender/ Cisco UmbrellaCisco Umbrella Root CACisco5 Cisco UmbrellaCisco Umbrella Primary SubCACiscoO ContentKeeper"ContentKeeper Appliance CA \(\d+\)ContentKeeper Technologies3 Cyberoam FirewallCyberoam Certificate Authority1 ForcePointForcepoint Cloud CAForcepoint LLC# Fortigate FortiGate CAFortinet FortinetFortinet( Ltd\.)?M Kaspersky Internet Security.Kaspersky Anti-Virus Personal Root Certificate( McAfee Web GatewayMcAfee Web Gateway( NetSparkwww\.netspark\.comNetSparkD SmoothWall Firewall-Smoothwall-default-root-certificate-authority@ SonicWall Firewall*HTTPS Management Certificate for SonicWALL+ SophosSophos SSL CA_[A-Z0-9\-]+Sophos SophosSophos_CA_[A-Z0-9]++ Sophos UTMsophosutm Proxy CA sophosutm8 Sophos Web ApplianceSophos Web Appliance Sophos Plc! Symantec Blue Coat Blue Coat.*> /Trend Micro InterScan Web Security Suite (IWSS) IWSS\.TREND Zscaler Zscaler Inc\.Wn6)XEdNZ'@ tfh{)GJQkܧt(ZlK",|d1. `)fhVGP/ g_w 8^ JLAd ťAA.M 6 N@KvH+FȬ;%m{}7Inz Zܢzڒ F?Y0awDTv|*^5Tk7Z,+c1Hx&2bǿF}|Ž?_s2ywxU? 6Lkxa _T7 uɨI+'3gFG*&/p(H0b #i{gSiK FYL&g'lrzNmj22G0jn.6DR؂\?^l>\g@0w̹6od+[tA JD?9lJiuc\,hhFOR`h;3JUmNϑKf?@A{B#rDϹw#yQ⾋khmr>3o%:.C܅]۵YρMOc^ztޠul+r:WG~(d|bHTi-[V& TY灙kUH3-V#o&3 eԩtH_8Ad"U;r ۠\"nV- gX571G_vU+f=jj?-i_mATUd U%=\;HAv\Ǹi 5~M?Qqi28 5ru d@ ƞ8Wޤ6oc_Ryq 7W MU݄ehQ-OJ#YՏ³#dԚ*GNxmP{uZ#Qt`vF^d+oziz)e~Pz`/clWI!6[)$3 [NrR4ĎB"IEGvX[o7~ׯ8 (ǗE4M1je/@P6Jr{!9WhEb0U=ј;-%փD7lH@D b+rqUXo]:$ܣ~bAVNgno2+i7gun۽܄^9gjM@ $&_'&Oa$ J,xc#nfh$ȣBebIha|şJoYL@!z-Xlg~'c6z+bã3Ć|_Cڅ~x /{Y20TW;2_gcx^hɍ6f;E_{aP霩`Laq2ڈw(19|>?[M$056{['?|c"60BzѰ@fd)zP%yaFT;CjlϙFF;7;@Ls<(:"leYTWpzYQ\!pk5.&D޷7u F9q@x;m z_!-Zㆭtk̖`hpb5jr=lo,ƛaAYܚc%C%:=.^2#t*,,U l(wjƈl n νn OWO,dI}qޜy1O%.e<žLSL6@>ՄrwYyn7!YD?H:ܼl\ kWWv]{,iꁨƔӝD0nMϩM0a2 q.lX5?--7n`yInmY;NmCϥ1Hˇ}:ջ2ӆBƜ8C[)yh3iql؜Qw5tٗ 55+"J+#"M-6t.`ENS%n[.>"M|lwd BC)YR+Hq=m)*ik-Mn!P}\Jȝz3Λ+rosi:~2躀Mn48Sf*Wm{87aG{u$_!&d-Q^mHX^D}lw4JUCX-L Ԇm>l|Íŭ$$WOFhCoXv#Y©[Olhqb|]H(飹ȓkT,ek";J^b=GD&]~a(/iVl*к"SΨ%,w*[ =Ps89s5RJbiG0FSenR<{\X|LğfBxq8Vfo'\d *A (Zȸj5b3l8Sj 5$5\B'֩̊UU_։0ؒծHDz`t2xޕq_%E_9bLbP8v {PshdiLmo6{n84Χ42{!Mr (Em5TRo~HJ,ɢ#N/$W>o|Dys Fcvw1cOd2vAtbn| >b! 'ȃ}Q| Q}p=.x{yLf!aKFq N.0!FBA0AG6l&G8lܺL`cBkQ7'\FN`D#xC!vRp`, "BpC'l]з%[Tǔ;ES0.F-0$$`]n8:I=Ke⢛2nRASʘje.Ʊo+V`F0uwIJV54FW8l3*YPG˙d6:uEb( C StrK pZ6Eή]fn6t19UW|R\iHb!ꌛu+`À2~`5|Ugt(0FtE< 7mSȌ|!s:MqB{)"I;C $=GC8B<a$()dqD=cu2Jf`hHYNeJ?S/wּo&д٭Ev ,cTsdUc:1f5fMT_tU{566.*l`Y, 4Ԃۀ7$5ˮ.\NiƙA Ooh(sQ2ֺhfh/MK)w3 ]W>歕vFՠ|h?=xGo94?0VEk?Ȧj1nG4 |d?@ȝ=)l'=yKl}EJAM8Xm#Z/JmgmJj^e;bHՠxzDﳌ%~.,q:es=8>Cٞ2Vc5kj(ͼwfhw[ ϭjs5R:S:b%rګ+VRDݥRnR֮R|¿=RK48`Qn?o=Гf_v~4K" su9fJ9 j˨Ԛ"yg&A!:PN+:H7QaJTؼ|_f< =x4bh53=xpMɓ)e f n|nej"gTJ"71 w: g4mUbSCAb4ǘqS*:O+,wv}Ah!`oc [أ_($)=/NEݹOZjpk+mhӥE(B !) YyzqvK_l-ԇhVLwj<8%EӤ1!S;23(L=?BJANj' , PŊ{\5IB;Wiދ Đ!T̲gచ"Em8Yiem=TN5W{QRXZZ]}x{?>iZ _aGKa9z,gRn.UadjSnWřh K-cA.%=x-Ϙ6 K8e:C/}BgK릏JEO@S $=$\ [;GПŀ"+d\KG] K&hK ket?#X)* ec6%}QfHԿ]\2/ x|,'y>2:xyB^iiwm窟>U2sX8 ]aӲ(BmZt[|=AǖT2$46q7Ueabf@AT’#o~mÆ^4'~T~ u rIr#z]tD/0q/zFd },ldi\┬hcY3[%7MUX/HCnG3*;6("-/^ $i18n{extensionSettings}
$i18n{loading}
$i18n{title}

$i18n{incognitoTabHeading}

$i18n{incognitoTabDescription} $i18n{learnMore}

$i18nRaw{incognitoTabFeatures}
$i18nRaw{incognitoTabWarning}
$i18n{learnMore}
$i18n{title}

$i18n{guestTabHeading}

$i18n{guestTabDescription}

$i18n{learnMore}
/* 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 { background-attachment: fixed; background-color: $i18n{colorBackground}; background-position: $i18n{backgroundBarDetached}; background-repeat: $i18n{backgroundTiling}; height: 100%; overflow: auto; } html[hascustombackground='true'] { background-image: url(chrome://theme/IDR_THEME_NTP_BACKGROUND?$i18n{themeId}); } html[bookmarkbarattached='true'] { background-position: $i18n{backgroundBarAttached}; } #attribution-img { content: url(chrome://theme/IDR_THEME_NTP_ATTRIBUTION?$i18n{themeId}); } $i18n{title}
$i18n{attributionintro}
/* 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. */ html { background-attachment: fixed; background-color: $i18n{colorBackground}; background-image: url(chrome://theme/IDR_THEME_NTP_BACKGROUND?$i18n{themeId}); background-position: $i18n{backgroundBarDetached}; background-repeat: $i18n{backgroundTiling}; } #attribution { left: $i18n{leftAlignAttribution}; right: $i18n{rightAlignAttribution}; text-align: $i18n{textAlignAttribution}; display: $i18n{displayAttribution}; } #attribution-img { content: url(chrome://theme/IDR_THEME_NTP_ATTRIBUTION?$i18n{themeId}); } html[bookmarkbarattached='true'] { background-position: $i18n{backgroundBarAttached}; } body { color: $i18n{colorTextRgba}; height: 100%; overflow: auto; } #attribution, [is='action-link'] { color: $i18n{colorTextLight}; } [is='action-link']:active { color: $i18n{colorTextRgba}; } .page-switcher { color: rgba($i18n{colorText}, 0.5); } .page-switcher:hover, .page-switcher:focus, .page-switcher.drag-target { background-color: rgba($i18n{colorText}, 0.06); } /* Only change the background to a gradient when a promo is showing. */ .showing-login-area #page-switcher-end:hover, .showing-login-area #page-switcher-end:focus, .showing-login-area #page-switcher-end.drag-target { background: linear-gradient( rgba($i18n{colorText}, 0) 0, rgba($i18n{colorText}, .01) 60px, rgba($i18n{colorText}, .06) 183px); } .tile-page-scrollbar { background-color: $i18n{colorTextLight}; } /* Footer *********************************************************************/ #footer-border { background: linear-gradient(to left, rgba($i18n{colorSectionBorder}, 0.2), rgba($i18n{colorSectionBorder}, 0.3) 20%, rgba($i18n{colorSectionBorder}, 0.3) 80%, rgba($i18n{colorSectionBorder}, 0.2)); } .dot input:focus { background-color: $i18n{colorBackground}; } .filler .thumbnail { border-color: $i18n{colorBackground}; } /* Copyright 2016 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 { align-items: center; color: var(--paper-grey-900); display: flex; flex-direction: column; font-size: 100%; justify-content: center; margin: 0; min-height: 100vh; } @keyframes slideUpContent { from { transform: translateY(186px); } } @keyframes fadeIn { from { opacity: 0; } } @keyframes fadeOut { to { opacity: 0; } } @keyframes fadeInAndSlideUp { from { opacity: 0; transform: translateY(8px); } } @keyframes spin { from { transform: rotate(1440deg) scale(0.8); } } @keyframes fadeInAndSlideDownShadow { from { opacity: .6; top: 0; } } @keyframes scaleUp { 0% { transform: scale(.8); } } @keyframes colorize { from { filter: grayscale(100%) brightness(128%) contrast(20%) brightness(161%); opacity: .6; } } @keyframes bounce { 0% { transform: matrix3d(0.8, 0, 0, 0, 0, 0.8, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 7.61% { transform: matrix3d(0.907, 0, 0, 0, 0, 0.907, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 11.41% { transform: matrix3d(0.948, 0, 0, 0, 0, 0.948, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 15.12% { transform: matrix3d(0.976, 0, 0, 0, 0, 0.976, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 18.92% { transform: matrix3d(0.996, 0, 0, 0, 0, 0.996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 22.72% { transform: matrix3d(1.008, 0, 0, 0, 0, 1.008, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 30.23% { transform: matrix3d(1.014, 0, 0, 0, 0, 1.014, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 50.25% { transform: matrix3d(1.003, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 70.27% { transform: matrix3d(0.999, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } } .content { height: 100%; overflow-y: hidden; } .slider { align-items: center; animation: slideUpContent 600ms 1.8s cubic-bezier(.4, .2, 0, 1) both; display: flex; flex: 1; flex-direction: column; justify-content: center; max-width: 500px; } .heading { animation: fadeInAndSlideUp 600ms 1.9s cubic-bezier(.4, .2, 0, 1) both; font-size: 2.125em; margin-bottom: .25em; margin-top: 1.5em; text-align: center; } .subheading { animation: fadeInAndSlideUp 600ms 1.9s cubic-bezier(.4, .2, 0, 1) both; color: #939393; font-size: 1em; font-weight: 500; margin-top: .25em; text-align: center; } .logo { animation: fadeIn 600ms both, bounce 1s 600ms linear both; height: 96px; position: relative; width: 96px; } .logo-icon { animation: spin 2.4s cubic-bezier(.4, .2, 0, 1) both, colorize 300ms 700ms linear both; background-image: -webkit-image-set(url(chrome://welcome/logo.png) 1x, url(chrome://welcome/logo2x.png) 2x); background-size: 100%; height: 96px; width: 96px; } .logo-shadow { animation: fadeInAndSlideDownShadow 300ms 600ms both; background: rgba(0, 0, 0, .2); border-radius: 50%; filter: blur(16px); height: 96px; position: absolute; top: 16px; width: 96px; z-index: -1; } .signin { animation: fadeInAndSlideUp 600ms 2s cubic-bezier(.4, .2, 0, 1) both; margin-top: 3em; text-align: left; } .signin-description { font-size: .875em; line-height: 1.725em; max-width: 344px; } .signin-buttons { align-items: center; display: flex; flex-direction: column; margin-top: 2em; } .action { -webkit-font-smoothing: antialiased; background: var(--google-blue-500); border-radius: 2px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .1); color: white; font-size: .8125em; font-weight: 500; line-height: 2.25rem; padding: 0 1.5em; transition: 300ms cubic-bezier(.4, .2, 0, 1); will-change: box-shadow; } .action:hover { background: var(--paper-blue-a400); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .1), 0 1px 2px rgba(0, 0, 0, .24); } .action:active { background: var(--google-blue-500); } .action.keyboard-focus { background: var(--google-blue-700); } .link { color: var(--google-blue-700); display: inline-block; font-size: .8125em; margin: 1.5em; text-align: center; text-decoration: none; } .watermark { -webkit-mask-image: url(chrome://resources/images/google_logo.svg); -webkit-mask-repeat: no-repeat; -webkit-mask-size: 100%; animation: fadeIn 1s cubic-bezier(0, 0, .2, 1) both; background: var(--paper-grey-400); bottom: 24px; height: 24px; position: absolute; width: 74px; } @media(max-height: 608px) { .watermark { display: none; } } $i18n{headerText}
$i18n{headerText}
// Copyright 2016 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('welcome', function() { 'use strict'; function onAccept(e) { chrome.send('handleActivateSignIn'); } function onDecline(e) { chrome.send('handleUserDecline'); e.preventDefault(); } function initialize() { $('accept-button').addEventListener('click', onAccept); $('decline-button').addEventListener('click', onDecline); var logo = document.querySelector('.logo-icon'); logo.onclick = function(e) { logo.animate( { transform: ['none', 'rotate(-10turn)'], }, /** @type {!KeyframeEffectOptions} */ ({ duration: 500, easing: 'cubic-bezier(1, 0, 0, 1)', })); }; } return {initialize: initialize}; }); document.addEventListener('DOMContentLoaded', welcome.initialize); /* Copyright 2017 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 { align-items: center; color: var(--google-grey-700); display: flex; flex-direction: column; font-size: 100%; justify-content: center; margin: 0; min-height: 100vh; text-align: center; } .watermark { -webkit-mask-image: url(chrome://resources/images/google_logo.svg); -webkit-mask-repeat: no-repeat; -webkit-mask-size: 100%; animation: fadeIn 1s cubic-bezier(0, 0, .2, 1) both; background: var(--paper-grey-400); bottom: 24px; height: 24px; position: absolute; width: 74px; } @media(max-height: 608px) { .watermark { display: none; } } $i18n{headerText}
// Copyright 2017 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 helper object used by the welcome page to interact with * the browser. */ cr.define('welcome', function() { /** @interface */ class WelcomeBrowserProxy { handleActivateSignIn() {} handleUserDecline() {} } /** @implements {welcome.WelcomeBrowserProxy} */ class WelcomeBrowserProxyImpl { /** @override */ handleActivateSignIn() { chrome.send('handleActivateSignIn'); } /** @override */ handleUserDecline() { chrome.send('handleUserDecline'); } } cr.addSingletonGetter(WelcomeBrowserProxyImpl); return { WelcomeBrowserProxy: WelcomeBrowserProxy, WelcomeBrowserProxyImpl: WelcomeBrowserProxyImpl, }; }); // Copyright 2017 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: 'welcome-app', welcomeBrowserProxy_: null, /** @override */ ready: function() { this.welcomeBrowserProxy_ = welcome.WelcomeBrowserProxyImpl.getInstance(); }, /** @private */ onAccept_: function() { this.welcomeBrowserProxy_.handleActivateSignIn(); }, /** @private */ onDecline_: function() { this.welcomeBrowserProxy_.handleUserDecline(); }, /** @private */ onLogoTap_: function() { this.$.logo.animate( { transform: ['none', 'rotate(-10turn)'], }, /** @type {!KeyframeEffectOptions} */ ({ duration: 500, easing: 'cubic-bezier(1, 0, 0, 1)', })); }, }); /* Copyright 2016 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 { align-items: center; box-sizing: border-box; color: var(--paper-grey-900); display: flex; flex-direction: column; font-size: 100%; justify-content: center; margin: 0; min-height: 100vh; } $i18n{headerText} // Copyright 2016 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: 'welcome-win10', properties: { // Determines if the combined variant should be displayed. The combined // variant includes instructions on how to pin Chrome to the taskbar. isCombined: Boolean, // Indicates if the accelerated flow is enabled. isAccelerated: Boolean, }, receivePinnedState_: function(isPinnedToTaskbar) { // Allow overriding of the result via a query parameter. // TODO(pmonette): Remove these checks when they are no longer needed. const VARIANT_KEY = 'variant'; const VARIANT_TYPE_MAP = {'defaultonly': false, 'combined': true}; var params = new URLSearchParams(location.search); if (params.has(VARIANT_KEY) && params.get(VARIANT_KEY) in VARIANT_TYPE_MAP) this.isCombined = VARIANT_TYPE_MAP[params.get(VARIANT_KEY)]; else this.isCombined = !isPinnedToTaskbar; // Show the module. this.style.opacity = 1; }, ready: function() { this.isCombined = false; this.isAccelerated = false; const FLOWTYPE_KEY = 'flowtype'; const FLOW_TYPE_MAP = {'regular': false, 'accelerated': true}; var params = new URLSearchParams(location.search); if (params.has(FLOWTYPE_KEY)) { if (params.get(FLOWTYPE_KEY) in FLOW_TYPE_MAP) { this.isAccelerated = FLOW_TYPE_MAP[params.get(FLOWTYPE_KEY)]; // Adjust the height since the accelerated flow contains fewer steps. this.customStyle['--expandable-section-height'] = '26.375em'; this.updateStyles(); } else { console.log( 'Found invalid value for the \'flowtype\' parameter: %s', params.get(FLOWTYPE_KEY)); } } // Asynchronously check if Chrome is pinned to the taskbar. cr.sendWithPromise('getPinnedToTaskbarState') .then(this.receivePinnedState_.bind(this)); }, computeClasses: function(isCombined) { return isCombined ? 'section expandable expanded' : 'section'; }, onContinue: function() { chrome.send('handleContinue'); }, onOpenSettings: function() { chrome.send('handleSetDefaultBrowser'); }, onToggle: function() { if (!this.isCombined) return; var sections = this.shadowRoot.querySelectorAll('.section.expandable'); sections.forEach(function(section) { var isExpanded = section.classList.toggle('expanded'); section.querySelector('[role~="button"]') .setAttribute('aria-expanded', isExpanded); }); } }); // 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; /** * 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=' }); /** * @type {boolean} */ var firstLoad = true; /** * Command shortcut mapping. * @const */ var commandShortcutMap = cr.isMac ? { 'edit': 'Enter', // On Mac we also allow Meta+Backspace. 'delete': 'Delete Backspace Meta|Backspace', '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|z', } : { 'edit': 'F2', 'delete': 'Delete', '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|z', }; /** * 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 wasFirstLoad = firstLoad; firstLoad = false; 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); if (wasFirstLoad) { setTimeout(function() { chrome.metricsPrivate.recordTime( 'BookmarkManager.ResultsRenderedTime', Math.floor(window.performance.now())); }); } }); } } // 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|undefined} 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'); 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; }); 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'); // Use the same histogram configuration as UMA_HISTOGRAM_COUNTS_1000(). chrome.metricsPrivate.recordValue({ 'metricName': 'Bookmarks.BookmarksInFolder', 'type': chrome.metricsPrivate.MetricTypeType.HISTOGRAM_LOG, 'min': 1, 'max': 1000, 'buckets': 50 }, this.dataModel.length); }, /** * 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'; var labelImgWrapper = this.ownerDocument.createElement('div'); labelImgWrapper.className = 'label-img-wrapper'; var labelImg = this.ownerDocument.createElement('div'); var labelText = this.ownerDocument.createElement('div'); labelText.className = 'label-text'; labelText.textContent = bookmarkNode.title; var urlEl = this.ownerDocument.createElement('div'); urlEl.className = 'url'; if (bmm.isFolder(bookmarkNode)) { this.className = 'folder'; // TODO(pkasting): Condense folder icon resources together. labelImg.style.content = cr.icon.getImage( cr.isMac ? 'chrome://theme/IDR_BOOKMARK_BAR_FOLDER' : 'chrome://theme/IDR_FOLDER_CLOSED'); } else { labelImg.style.content = cr.icon.getFavicon(bookmarkNode.url); urlEl.textContent = bookmarkNode.url; } labelImgWrapper.appendChild(labelImg); labelEl.appendChild(labelImgWrapper); labelEl.appendChild(labelText); 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 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.key) { case 'Escape': // 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 'Tab': // 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; var labelTextEl = queryRequiredElement('.label-text', this); var urlEl = queryRequiredElement('.url', this); if (editing) { this.setAttribute('editing', ''); this.draggable = false; labelInput = /** @type {HTMLElement} */(doc.createElement('input')); labelInput.placeholder = loadTimeData.getString('name_input_placeholder'); replaceAllChildren(labelTextEl, 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; labelTextEl.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); var dragTarget = getBookmarkElement(e.target); if (dragTarget instanceof ListItem || dragTarget instanceof BookmarkList) { chrome.metricsPrivate.recordUserAction( 'BookmarkManager_StartDragFromList'); } else if (dragTarget instanceof TreeItem) { chrome.metricsPrivate.recordUserAction( 'BookmarkManager_StartDragFromTree'); } chrome.metricsPrivate.recordSmallCount( 'BookmarkManager.NumDragged', draggedNodes.length); } } 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(); var dragTarget = getBookmarkElement(e.target); var action; if (dragTarget instanceof ListItem || dragTarget instanceof BookmarkList) { action = 'BookmarkManager_DropToList'; if (dropDestination.position == DropPosition.ON) action = 'BookmarkManager_DropToListItem'; } else if (dragTarget instanceof TreeItem) { action = 'BookmarkManager_DropToTree'; if (dropDestination.position == DropPosition.ON) action = 'BookmarkManager_DropToTreeItem'; } if (action) chrome.metricsPrivate.recordUserAction(action); } 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 }; }); $i18n{title}

// Copyright 2016 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'; /** * The status of sending the feedback report as defined in feedback_private.idl. * @enum {string} */ var ReportStatus = {SUCCESS: 'success', DELAYED: 'delayed'}; // 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. // // Copyright 2016 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'; /** * The status of sending the feedback report as defined in feedback_private.idl. * @enum {string} */ var ReportStatus = {SUCCESS: 'success', DELAYED: 'delayed'}; /** * @type {number} * @const */ var FEEDBACK_WIDTH = 500; /** * @type {number} * @const */ var FEEDBACK_HEIGHT = 610; /** * @type {string} * @const */ var FEEDBACK_DEFAULT_WINDOW_ID = 'default_window'; // To generate a hashed extension ID, use a sha-1 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 '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 // TODO (ntang) Remove the following 2 hashes by 12/31/2017. 'B620CF4203315F9F2E046EDED22C7571A935958D', // http://crbug.com/510270 'B206D8716769728278D2D300349C6CB7D7DE2EF9', // http://crbug.com/510270 'EFCF5358672FEE04789FD2EC3638A67ADEDB6C8C', // http://crbug.com/514696 'FAD85BC419FE00995D196312F53448265EFA86F1', // http://crbug.com/516527 'F33B037DEDA65F226B7409C2ADB0CF3F8565AB03', // http://crbug.com/541769 '969C788BCBC82FBBE04A17360CA165C23A419257', // http://crbug.com/541769 '3BC3740BFC58F06088B300274B4CFBEA20136342', // http://crbug.com/541769 '2B6C6A4A5940017146F3E58B7F90116206E84685', // http://crbug.com/642141 '96FF2FFA5C9173C76D47184B3E86D267B37781DE', // http://crbug.com/642141 ]; /** * Used to generate unique IDs for FeedbackRequest objects. * @type {number} */ var lastUsedId = 0; /** * A FeedbackRequest object represents a unique feedback report, requested by an * instance of the feedback window. It contains the system information specific * to this report, the full feedbackInfo, and callbacks to send the report upon * request. */ class FeedbackRequest { constructor(feedbackInfo) { this.id_ = ++lastUsedId; this.feedbackInfo_ = feedbackInfo; this.onSystemInfoReadyCallback_ = null; this.isSystemInfoReady_ = false; this.reportIsBeingSent_ = false; this.isRequestCanceled_ = false; this.useSystemInfo_ = false; } /** * Called when the system information is sent from the C++ side. * @param {Object} sysInfo The received system information. */ getSystemInformationCallback(sysInfo) { if (this.isRequestCanceled_) { // If the window had been closed before the system information was // received, we skip the rest of the operations and return immediately. return; } this.isSystemInfoReady_ = true; // Combine the newly received system information with whatever system // information we have in the feedback info (if any). if (this.feedbackInfo_.systemInformation) { this.feedbackInfo_.systemInformation = this.feedbackInfo_.systemInformation.concat(sysInfo); } else { this.feedbackInfo_.systemInformation = sysInfo; } if (this.onSystemInfoReadyCallback_ != null) { this.onSystemInfoReadyCallback_(); this.onSystemInfoReadyCallback_ = null; } } /** * Retrieves the system information for this request object. * @param {function()} callback Invoked to notify the listener that the system * information has been received. */ getSystemInformation(callback) { if (this.isSystemInfoReady_) { callback(); return; } this.onSystemInfoReadyCallback_ = callback; // The C++ side must reply to the callback specific to this object. var boundCallback = this.getSystemInformationCallback.bind(this); chrome.feedbackPrivate.getSystemInformation(boundCallback); } /** * Sends the feedback report represented by the object, either now if system * information is ready, or later once it is. * @param {boolean} useSystemInfo True if the user would like the system * information to be sent with the report. */ sendReport(useSystemInfo) { this.reportIsBeingSent_ = true; this.useSystemInfo_ = useSystemInfo; if (useSystemInfo && !this.isSystemInfoReady_) { this.onSystemInfoReadyCallback_ = this.sendReportNow; return; } this.sendReportNow(); } /** * Sends the report immediately and removes this object once the report is * sent. */ sendReportNow() { if (!this.useSystemInfo_) { // Clear the system information if the user doesn't want it to be sent. this.feedbackInfo_.systemInformation = null; } /** @const */ var ID = this.id_; /** @const */ var FLOW = this.feedbackInfo_.flow; chrome.feedbackPrivate.sendFeedback(this.feedbackInfo_, function(result) { if (result == ReportStatus.SUCCESS) { console.log('Feedback: Report sent for request with ID ' + ID); if (FLOW != chrome.feedbackPrivate.FeedbackFlow.LOGIN) window.open(FEEDBACK_LANDING_PAGE, '_blank'); } else { console.log( 'Feedback: Report for request with ID ' + ID + ' will be sent later.'); } }); } /** * Handles the event when the feedback UI window corresponding to this * FeedbackRequest instance is closed. */ onWindowClosed() { if (!this.reportIsBeingSent_) this.isRequestCanceled_ = true; } } /** * 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}); } /** * 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) { var win = chrome.app.window.get(FEEDBACK_DEFAULT_WINDOW_ID); if (win) { win.show(); return; } chrome.app.window.create( 'html/default.html', { frame: feedbackInfo.useSystemWindowFrame ? 'chrome' : 'none', id: FEEDBACK_DEFAULT_WINDOW_ID, innerBounds: { minWidth: FEEDBACK_WIDTH, minHeight: FEEDBACK_HEIGHT, }, hidden: true, resizable: false }, function(appWindow) { var request = new FeedbackRequest(feedbackInfo); // The feedbackInfo member of the new window should refer to the one in // its corresponding FeedbackRequest object to avoid copying and // duplicatations. appWindow.contentWindow.feedbackInfo = request.feedbackInfo_; // Define some functions for the new window so that it can call back // into here. // Define a function for the new window to get the system information. appWindow.contentWindow.getSystemInformation = function(callback) { request.getSystemInformation(callback); }; // Define a function to request sending the feedback report. appWindow.contentWindow.sendFeedbackReport = function(useSystemInfo) { request.sendReport(useSystemInfo); }; // Observe when the window is closed. appWindow.onClosed.addListener(function() { request.onWindowClosed(); }); }); } 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 SRT_DOWNLOAD_PAGE = 'https://www.google.com/chrome/cleanup-tool/'; /** @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 FEEDBACK_MIN_HEIGHT_LOGIN = 482; /** @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'; /** * SRT Prompt Result defined in feedback_private.idl. * @enum {string} */ var SrtPromptResult = { ACCEPTED: 'accepted', // User accepted prompt. DECLINED: 'declined', // User declined prompt. CLOSED: 'closed', // User closed window without responding to prompt. }; var attachedFileBlob = null; var lastReader = null; /** * Determines whether the system information associated with this instance of * the feedback window has been received. * @type {boolean} */ var isSystemInfoReady = false; /** * Indicates whether the SRT Prompt is currently being displayed. * @type {boolean} */ var isShowingSrtPrompt = false; /** * The callback used by the sys_info_page to receive the event that the system * information is ready. * @type {function(sysInfo)} */ var sysInfoPageOnSysInfoReadyCallback = 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-drop-down').value; var useSystemInfo = false; var useHistograms = false; if ($('sys-info-checkbox') != null && $('sys-info-checkbox').checked) { // Send histograms along with system info. useSystemInfo = useHistograms = true; } // feedbackInfo.sendHistograms = useHistograms; // If the user doesn't want to send the screenshot. if (!$('screenshot-checkbox').checked) feedbackInfo.screenshot = null; var productId = parseInt('' + feedbackInfo.productId); if (isNaN(productId)) { // For apps that still use a string value as the |productId|, we must clear // that value since the API uses an integer value, and a conflict in data // types will cause the report to fail to be sent. productId = null; } feedbackInfo.productId = productId; // Request sending the report, show the landing page (if allowed), and close // this window right away. The FeedbackRequest object that represents this // report will take care of sending the report in the background. sendFeedbackReport(useSystemInfo); scheduleWindowClose(); return true; } /** * Click listener for the cancel button. * @param {Event} e The click event being handled. */ function cancel(e) { e.preventDefault(); scheduleWindowClose(); } // 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; var minHeight = FEEDBACK_MIN_HEIGHT; if (feedbackInfo.flow == chrome.feedbackPrivate.FeedbackFlow.LOGIN) minHeight = FEEDBACK_MIN_HEIGHT_LOGIN; height = Math.max(height, minHeight); chrome.app.window.current().resizeTo(width, height); } /** * A callback to be invoked when the background page of this extension receives * the system information. */ function onSystemInformation() { isSystemInfoReady = true; // In case the sys_info_page needs to be notified by this event, do so. if (sysInfoPageOnSysInfoReadyCallback != null) { sysInfoPageOnSysInfoReadyCallback(feedbackInfo.systemInformation); sysInfoPageOnSysInfoReadyCallback = null; } } /** * Close the window after 100ms delay. */ function scheduleWindowClose() { setTimeout(function() { window.close(); }, 100); } /** * 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) { if (!feedbackInfo.flow) feedbackInfo.flow = chrome.feedbackPrivate.FeedbackFlow.REGULAR; if (feedbackInfo.flow == chrome.feedbackPrivate.FeedbackFlow.SHOW_SRT_PROMPT) { isShowingSrtPrompt = true; $('content-pane').hidden = true; $('srt-decline-button').onclick = function() { isShowingSrtPrompt = false; chrome.feedbackPrivate.logSrtPromptResult(SrtPromptResult.DECLINED); $('srt-prompt').hidden = true; $('content-pane').hidden = false; }; $('srt-accept-button').onclick = function() { chrome.feedbackPrivate.logSrtPromptResult(SrtPromptResult.ACCEPTED); window.open(SRT_DOWNLOAD_PAGE, '_blank'); scheduleWindowClose(); }; $('close-button').addEventListener('click', function() { if (isShowingSrtPrompt) { chrome.feedbackPrivate.logSrtPromptResult(SrtPromptResult.CLOSED); } }); } else { $('srt-prompt').hidden = true; } $('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(); screenshotCanvas.toBlob(function(blob) { $('screenshot-image').src = URL.createObjectURL(blob); // Only set the alt text when the src url is available, otherwise we'd // get a broken image picture instead. crbug.com/773985. $('screenshot-image').alt = 'screenshot'; $('screenshot-image') .classList.toggle( 'wide-screen', $('screenshot-image').width > MAX_SCREENSHOT_WIDTH); feedbackInfo.screenshot = blob; }); }); chrome.feedbackPrivate.getUserEmail(function(email) { // Never add an empty option. if (!email) return; var optionElement = document.createElement('option'); optionElement.value = email; optionElement.text = email; optionElement.selected = true; // Make sure the "Report anonymously" option comes last. $('user-email-drop-down') .insertBefore(optionElement, $('anonymous-user-option')); // Now we can unhide the user email section: $('user-email').hidden = false; }); // Initiate getting the system info. isSystemInfoReady = false; getSystemInformation(onSystemInformation); // 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; } // No URL and file attachment for login screen feedback. if (feedbackInfo.flow == chrome.feedbackPrivate.FeedbackFlow.LOGIN) { $('page-url').hidden = true; $('attach-file-container').hidden = true; $('attach-file-note').hidden = true; } // chrome.feedbackPrivate.getStrings(feedbackInfo.flow, function(strings) { loadTimeData.data = strings; i18nTemplate.process(document, loadTimeData); if ($('sys-info-url')) { // Opens a new window showing the full anonymized system+app // information. $('sys-info-url').onclick = function() { var win = chrome.app.window.get(SYSINFO_WINDOW_ID); if (win) { win.show(); return; } chrome.app.window.create( '/html/sys_info.html', { frame: 'chrome', id: SYSINFO_WINDOW_ID, width: 640, height: 400, hidden: false, resizable: true }, function(appWindow) { // Define functions for the newly created window. // Gets the full system information for the new window. appWindow.contentWindow.getFullSystemInfo = function( callback) { if (isSystemInfoReady) { callback(feedbackInfo.systemInformation); return; } sysInfoPageOnSysInfoReadyCallback = callback; }; // Returns the loadTimeData for the new window. appWindow.contentWindow.getLoadTimeData = function() { return loadTimeData; }; }); }; } 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 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. /** * The global load time data that contains the localized strings that we will * get from the main page when this page first loads. */ var loadTimeData = null; /** * A queue of a sequence of closures that will incrementally build the sys info * html table. */ var tableCreationClosuresQueue = []; /** * The time used to post delayed tasks in MS. Currently set to be enough for two * frames. */ var STANDARD_DELAY_MS = 32; function getValueDivForButton(button) { return $(button.id.substr(0, button.id.length - 4)); } function getButtonForValueDiv(valueDiv) { return $(valueDiv.id + '-btn'); } /** * Expands the multiline table cell that contains the given valueDiv. * @param {HTMLElement} button The expand button. * @param {HTMLElement} valueDiv The div that contains the multiline logs. * @param {number} delayFactor A value used for increasing the delay after which * the cell will be expanded. Useful for expandAll() since it expands the * multiline cells one after another with each expension done slightly after * the previous one. */ function expand(button, valueDiv, delayFactor) { button.textContent = loadTimeData.getString('sysinfoPageCollapseBtn'); // Show the spinner container. var valueCell = valueDiv.parentNode; valueCell.firstChild.hidden = false; // Expanding huge logs can take a very long time, so we do it after a delay // to have a chance to render the spinner. setTimeout(function() { valueCell.className = 'number-expanded'; // Hide the spinner container. valueCell.firstChild.hidden = true; }, STANDARD_DELAY_MS * delayFactor); } /** * Collapses the multiline table cell that contains the given valueDiv. * @param {HTMLElement} button The expand button. * @param {HTMLElement} valueDiv The div that contains the multiline logs. */ function collapse(button, valueDiv) { button.textContent = loadTimeData.getString('sysinfoPageExpandBtn'); valueDiv.parentNode.className = 'number-collapsed'; } /** * Toggles whether an item is collapsed or expanded. */ function changeCollapsedStatus() { var valueDiv = getValueDivForButton(this); if (valueDiv.parentNode.className == 'number-collapsed') expand(this, valueDiv, 1); else collapse(this, valueDiv); } /** * Collapses all log items. */ function collapseAll() { var valueDivs = document.getElementsByClassName('stat-value'); for (var i = 0; i < valueDivs.length; ++i) { if (valueDivs[i].parentNode.className != 'number-expanded') continue; var button = getButtonForValueDiv(valueDivs[i]); if (button) collapse(button, valueDivs[i]); } } /** * Expands all log items. */ function expandAll() { var valueDivs = document.getElementsByClassName('stat-value'); for (var i = 0; i < valueDivs.length; ++i) { if (valueDivs[i].parentNode.className != 'number-collapsed') continue; var button = getButtonForValueDiv(valueDivs[i]); if (button) expand(button, valueDivs[i], i + 1); } } function createNameCell(key) { var nameCell = document.createElement('td'); nameCell.setAttribute('class', 'name'); var nameDiv = document.createElement('div'); nameDiv.setAttribute('class', 'stat-name'); nameDiv.appendChild(document.createTextNode(key)); nameCell.appendChild(nameDiv); return nameCell; } function createButtonCell(key, isMultiLine) { var buttonCell = document.createElement('td'); buttonCell.setAttribute('class', 'button-cell'); if (isMultiLine) { var button = document.createElement('button'); button.setAttribute('id', '' + key + '-value-btn'); button.onclick = changeCollapsedStatus; button.textContent = loadTimeData.getString('sysinfoPageExpandBtn'); buttonCell.appendChild(button); } return buttonCell; } function createValueCell(key, value, isMultiLine) { var valueCell = document.createElement('td'); var valueDiv = document.createElement('div'); valueDiv.setAttribute('class', 'stat-value'); valueDiv.setAttribute('id', '' + key + '-value'); valueDiv.appendChild(document.createTextNode(value)); if (isMultiLine) { valueCell.className = 'number-collapsed'; var loadingContainer = $('spinner-container').cloneNode(true); loadingContainer.setAttribute('id', '' + key + '-value-loading'); loadingContainer.hidden = true; valueCell.appendChild(loadingContainer); } else { valueCell.className = 'number'; } valueCell.appendChild(valueDiv); return valueCell; } function createTableRow(key, value) { var row = document.createElement('tr'); // Avoid using element.scrollHeight as it's very slow. crbug.com/653968. var isMultiLine = value.split('\n').length > 2 || value.length > 1000; row.appendChild(createNameCell(key)); row.appendChild(createButtonCell(key, isMultiLine)); row.appendChild(createValueCell(key, value, isMultiLine)); return row; } /** * Finalize the page after the content has been loaded. */ function finishPageLoading() { $('collapseAllBtn').onclick = collapseAll; $('expandAllBtn').onclick = expandAll; $('spinner-container').hidden = true; } /** * Pops a closure from the front of the queue and executes it. */ function processQueue() { var closure = tableCreationClosuresQueue.shift(); if (closure) closure(); if (tableCreationClosuresQueue.length > 0) { // Post a task to process the next item in the queue. setTimeout(processQueue, STANDARD_DELAY_MS); } } /** * Creates a closure that creates a table row for the given key and value. * @param {string} key The name of the log. * @param {string} value The contents of the log. * @return {function():void} A closure that creates a row for the given log. */ function createTableRowWrapper(key, value) { return function() { $('detailsTable').appendChild(createTableRow(key, value)); }; } /** * Creates closures to build the system information table row by row * incrementally. * @param {Object} systemInfo The system information that will be used to fill * the table. */ function createTable(systemInfo) { for (var key in systemInfo) { var item = systemInfo[key]; tableCreationClosuresQueue.push( createTableRowWrapper(item['key'], item['value'])); } tableCreationClosuresQueue.push(finishPageLoading); processQueue(); } /** * Initializes the page when the window is loaded. */ window.onload = function() { loadTimeData = getLoadTimeData(); i18nTemplate.process(document, loadTimeData); getFullSystemInfo(createTable); }; // 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() { // If this dialog is using system window controls, these elements aren't // needed at all. if (window.feedbackInfo.useSystemWindowFrame) { $('minimize-button').hidden = true; $('close-button').hidden = true; return; } $('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() { scheduleWindowClose(); }); $('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; display: flex; flex-direction: column; height: 100%; margin: 0; overflow: auto; padding: 0; width: 100%; } [hidden] { display: none !important; } .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; min-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; flex-grow: 1; 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 #additional-info-label { -webkit-margin-start: 10px; } .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 > select { -webkit-padding-start: 5px; border: 1px solid #c8c8c8; color: #585858; flex: 1 1 auto; height: 100%; } .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-end: 25px; display: block; height: 60px; margin-top: 40px; transition: all 250ms ease; } .content #screenshot-image:hover { -webkit-margin-end: 0; 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 #screenshot-label { flex: auto; } .content #privacy-note { color: #969696; font-size: 10px; line-height: 15px; margin-bottom: 20px; margin-top: 20px; } .content .buttons-pane { bottom: 20px; display: -webkit-flex; justify-content: flex-end; left: 20px; position: absolute; right: 20px; } .content .top-buttons { position: absolute; } .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); } .srt-image { -webkit-margin-end: auto; -webkit-margin-start: 40px; display: block; height: 50px; margin-bottom: 20px; margin-top: 120px; } .srt-body { font-size: 14px; line-height: 24px; margin: 0 40px; } /* Copyright 2016 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 { overflow: visible; } #detailsTable { margin-top: .5em; } #status { color: rgb(66, 133, 244); display: inline-block; margin: .5em .5em; }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
// 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().startsWith('https://accounts.google.com/')) 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 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); color: var(--primary-text-color); line-height: 154%; 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; } } /** * 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.originalUrl_ = this.browserApi_.getStreamInfo().originalUrl; this.loadState_ = LoadState.LOADING; this.parentWindow_ = null; this.parentOrigin_ = null; this.isFormFieldFocused_ = false; this.delayedScriptingMessages_ = []; this.isPrintPreview_ = location.origin === 'chrome://print'; this.isPrintPreviewLoaded_ = false; this.isUserInitiatedEvent_ = true; /** * @type {PDFMetrics} */ this.metrics = (chrome.metricsPrivate ? new PDFMetricsImpl() : new PDFMetricsDummy()); this.metrics.onDocumentOpened(); // Parse open pdf parameters. this.paramsParser_ = new OpenPDFParamsParser(this.getNamedDestination_.bind(this)); var toolbarEnabled = this.paramsParser_.getUiUrlParams(this.originalUrl_).toolbar && !this.isPrintPreview_; // 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'); if (this.isPrintPreview_) this.pageIndicator_ = $('page-indicator'); 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 = () => { chrome.tabs.reload(this.browserApi_.getStreamInfo().tabId); }; } // Create the viewport. var shortWindow = window.innerHeight < PDFViewer.TOOLBAR_WINDOW_MIN_HEIGHT; var topToolbarHeight = (toolbarEnabled) ? PDFViewer.MATERIAL_TOOLBAR_HEIGHT : 0; var defaultZoom = this.browserApi_.getZoomBehavior() == BrowserApi.ZoomBehavior.MANAGE ? this.browserApi_.getDefaultZoom() : 1.0; this.viewport_ = new Viewport( window, this.sizer_, this.viewportChanged_.bind(this), this.beforeZoom_.bind(this), this.afterZoom_.bind(this), this.setUserInitiated_.bind(this), getScrollbarWidth(), defaultZoom, 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_render_frame_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.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; this.plugin_.setAttribute('background-color', backgroundColor); this.plugin_.setAttribute('top-toolbar-height', topToolbarHeight); if (this.browserApi_.getStreamInfo().embedded) { this.plugin_.setAttribute( 'top-level-url', this.browserApi_.getStreamInfo().tabUrl); } else { this.plugin_.setAttribute('full-frame', ''); } document.body.appendChild(this.plugin_); // Setup the button event listeners. this.zoomToolbar_ = $('zoom-toolbar'); this.zoomToolbar_.addEventListener( 'fit-to-changed', this.fitToChanged_.bind(this)); this.zoomToolbar_.addEventListener( 'zoom-in', this.viewport_.zoomIn.bind(this.viewport_)); this.zoomToolbar_.addEventListener( 'zoom-out', this.viewport_.zoomOut.bind(this.viewport_)); this.gestureDetector_ = new GestureDetector(this.plugin_); this.gestureDetector_.addEventListener( 'pinchstart', this.onPinchStart_.bind(this)); this.sentPinchEvent_ = false; this.gestureDetector_.addEventListener( 'pinchupdate', this.onPinchUpdate_.bind(this)); this.gestureDetector_.addEventListener( 'pinchend', this.onPinchEnd_.bind(this)); if (toolbarEnabled) { this.toolbar_ = $('toolbar'); this.toolbar_.hidden = false; this.toolbar_.addEventListener('save', this.save_.bind(this)); this.toolbar_.addEventListener('print', this.print_.bind(this)); this.toolbar_.addEventListener( 'rotate-right', this.rotateClockwise_.bind(this)); // Must attach to mouseup on the plugin element, since it eats mousedown // and click events. this.plugin_.addEventListener( 'mouseup', this.toolbar_.hideDropdowns.bind(this.toolbar_)); this.toolbar_.docTitle = getFilenameFromURL(this.originalUrl_); } document.body.addEventListener('change-page', e => { this.viewport_.goToPage(e.detail.page); if (e.detail.origin == 'bookmark') this.metrics.onBookmarkFollowed(); else if (e.detail.origin == 'pageselector') this.metrics.onPageSelectorNavigation(); }); document.body.addEventListener('change-page-and-xy', e => { this.viewport_.goToPageAndXY(e.detail.page, e.detail.x, e.detail.y); if (e.detail.origin == 'bookmark') this.metrics.onFollowBookmark(); }); document.body.addEventListener('navigate', e => { var disposition = e.detail.newtab ? Navigator.WindowOpenDisposition.NEW_BACKGROUND_TAB : Navigator.WindowOpenDisposition.CURRENT_TAB; this.navigator_.navigate(e.detail.uri, disposition); }); document.body.addEventListener('dropdown-opened', e => { if (e.detail == 'bookmarks') this.metrics.onOpenBookmarksPanel(); }); this.toolbarManager_ = new ToolbarManager(window, this.toolbar_, this.zoomToolbar_); // Set up the ZoomManager. this.zoomManager_ = ZoomManager.create( this.browserApi_.getZoomBehavior(), this.viewport_, this.browserApi_.setZoom.bind(this.browserApi_), this.browserApi_.getInitialZoom()); this.viewport_.zoomManager = this.zoomManager_; 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)); document.addEventListener( 'contextmenu', this.handleContextMenuEvent_.bind(this)); var tabId = this.browserApi_.getStreamInfo().tabId; this.navigator_ = new Navigator( this.originalUrl_, this.viewport_, this.paramsParser_, new NavigatorDelegate(tabId)); this.viewportScroller_ = new ViewportScroller(this.viewport_, this.plugin_, window); // Request translated strings. 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; this.toolbarManager_.hideToolbarsAfterTimeout(e); var pageUpHandler = () => { // Go to the previous page if we are fit-to-page or fit-to-height. if (this.viewport_.isPagedMode()) { 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; } }; var pageDownHandler = () => { // Go to the next page if we are fit-to-page or fit-to-height. if (this.viewport_.isPagedMode()) { 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; } }; switch (e.keyCode) { case 9: // Tab key. this.toolbarManager_.showToolbarsForKeyboardNavigation(); return; case 27: // Escape key. if (!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 (!hasKeyModifiers(e)) { // 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 (!hasKeyModifiers(e)) { // 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.toolbar_ && (e.ctrlKey || e.metaKey) && e.altKey) { this.toolbarManager_.showToolbars(); this.toolbar_.selectPageNumber(); } return; case 219: // Left bracket key. if (e.ctrlKey) this.rotateCounterClockwise_(); return; case 220: // Backslash key. if (e.ctrlKey) this.zoomToolbar_.fitToggleFromHotKey(); return; case 221: // Right bracket key. 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 { // Show toolbars as a fallback. if (!(e.shiftKey || e.ctrlKey || e.altKey)) this.toolbarManager_.showToolbars(); } }, handleMouseEvent_: function(e) { if (e.type == 'mousemove') this.toolbarManager_.handleMouseMove(e); else if (e.type == 'mouseout') this.toolbarManager_.hideToolbarsForMouseOut(); }, handleContextMenuEvent_: function(e) { // Stop Chrome from popping up the context menu on long press. We need to // make sure the start event did not have 2 touches because we don't want // to block two finger tap opening the context menu. We check for // firesTouchEvents in order to not block the context menu on right click. if (e.sourceCapabilities.firesTouchEvents && !this.gestureDetector_.wasTwoFingerTouch()) { e.preventDefault(); } }, /** * @private * Rotate the plugin clockwise. */ rotateClockwise_: function() { this.metrics.onRotation(); this.plugin_.postMessage({type: 'rotateClockwise'}); }, /** * @private * Rotate the plugin counter-clockwise. */ rotateCounterClockwise_: function() { this.metrics.onRotation(); this.plugin_.postMessage({type: 'rotateCounterclockwise'}); }, /** * @private * Request to change the viewport fitting type. * @param {CustomEvent} e Event received with the new FittingType as detail. */ fitToChanged_: function(e) { if (e.detail.fittingType == FittingType.FIT_TO_PAGE) { this.viewport_.fitToPage(); this.toolbarManager_.forceHideTopToolbar(); } else if (e.detail.fittingType == FittingType.FIT_TO_WIDTH) { this.viewport_.fitToWidth(); } else if (e.detail.fittingType == FittingType.FIT_TO_HEIGHT) { this.viewport_.fitToHeight(); this.toolbarManager_.forceHideTopToolbar(); } if (e.detail.userInitiated) this.metrics.onFitTo(e.detail.fittingType); }, /** * @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; if (this.isPrintPreview_ && !this.isPrintPreviewLoaded_) 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} params The open params passed in the URL. */ handleURLParams_: function(params) { if (params.zoom) this.viewport_.setZoom(params.zoom); if (params.position) { this.viewport_.goToPageAndXY( params.page ? params.page : 0, params.position.x, params.position.y); } else if (params.page) { this.viewport_.goToPage(params.page); } if (params.view) { this.isUserInitiatedEvent_ = false; this.zoomToolbar_.forceFit(params.view); if (params.viewPosition) { var zoomedPositionShift = params.viewPosition * this.viewport_.zoom; var currentViewportPosition = this.viewport_.position; if (params.view == FittingType.FIT_TO_WIDTH) currentViewportPosition.y += zoomedPositionShift; else if (params.view == FittingType.FIT_TO_HEIGHT) currentViewportPosition.x += zoomedPositionShift; this.viewport_.position = currentViewportPosition; } this.isUserInitiatedEvent_ = true; } }, /** * @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.toolbar_) this.toolbar_.loadProgress = progress; if (progress == -1) { // Document load failed. this.errorScreen_.show(); this.sizer_.style.display = 'none'; if (this.passwordScreen_.active) { this.passwordScreen_.deny(); this.passwordScreen_.close(); } 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.originalUrl_, this.handleURLParams_.bind(this)); this.loadState_ = LoadState.SUCCESS; this.sendDocumentLoadedMessage_(); while (this.delayedScriptingMessages_.length > 0) this.handleScriptingMessage(this.delayedScriptingMessages_.shift()); 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) { document.documentElement.dir = strings.textdirection; document.documentElement.lang = strings.language; $('toolbar').strings = strings; $('zoom-toolbar').strings = strings; $('password-screen').strings = strings; $('error-screen').strings = strings; }, /** * @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.isUserInitiatedEvent_ = false; this.viewport_.setDocumentDimensions(this.documentDimensions_); this.isUserInitiatedEvent_ = true; // If we received the document dimensions, the password was good so we // can dismiss the password screen. if (this.passwordScreen_.active) this.passwordScreen_.close(); if (this.pageIndicator_) this.pageIndicator_.initialFadeIn(); if (this.toolbar_) { this.toolbar_.docLength = this.documentDimensions_.pageDimensions.length; } 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 '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_.show(); 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, Navigator.WindowOpenDisposition.NEW_BACKGROUND_TAB); } else { this.navigator_.navigate(message.data.url, message.data.disposition); } break; case 'printPreviewLoaded': this.isPrintPreviewLoaded_ = true; this.sendDocumentLoadedMessage_(); 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.originalUrl_); } this.bookmarks_ = message.data.bookmarks; if (this.toolbar_) { this.toolbar_.docTitle = document.title; this.toolbar_.bookmarks = this.bookmarks; } break; case 'setIsSelecting': this.viewportScroller_.setEnableScrolling(message.data.isSelecting); break; case 'setIsEditMode': // TODO(hnakashima): Replace this with final visual indication from UX. if (message.data.isEditMode) this.toolbar_.docTitle = document.title + ' (edit mode)'; else this.toolbar_.docTitle = document.title; 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'}); if (this.viewport_.pinchPhase == Viewport.PinchPhase.PINCH_START) { var position = this.viewport_.position; var zoom = this.viewport_.zoom; var pinchPhase = this.viewport_.pinchPhase; this.plugin_.postMessage({ type: 'viewport', userInitiated: true, zoom: zoom, xOffset: position.x, yOffset: position.y, pinchPhase: pinchPhase }); } }, /** * @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; var pinchVector = this.viewport_.pinchPanVector || {x: 0, y: 0}; var pinchCenter = this.viewport_.pinchCenter || {x: 0, y: 0}; var pinchPhase = this.viewport_.pinchPhase; this.plugin_.postMessage({ type: 'viewport', userInitiated: this.isUserInitiatedEvent_, zoom: zoom, xOffset: position.x, yOffset: position.y, pinchPhase: pinchPhase, pinchX: pinchCenter.x, pinchY: pinchCenter.y, pinchVectorX: pinchVector.x, pinchVectorY: pinchVector.y }); this.zoomManager_.onPdfZoomChange(); }, /** * @param {boolean} userInitiated The value to set |isUserInitiatedEvent_| * to. * @private * A callback that sets |isUserInitiatedEvent_| to |userInitiated|. */ setUserInitiated_: function(userInitiated) { if (this.isUserInitiatedEvent_ == userInitiated) { throw 'Trying to set user initiated to current value.'; } this.isUserInitiatedEvent_ = userInitiated; }, /** * @private * A callback that's called when an update to a pinch zoom is detected. * @param {!Object} e the pinch event. */ onPinchUpdate_: function(e) { // Throttle number of pinch events to one per frame. if (!this.sentPinchEvent_) { this.sentPinchEvent_ = true; window.requestAnimationFrame(() => { this.sentPinchEvent_ = false; this.viewport_.pinchZoom(e); }); } }, /** * @private * A callback that's called when the end of a pinch zoom is detected. * @param {!Object} e the pinch event. */ onPinchEnd_: function(e) { // Using rAF for pinch end prevents pinch updates scheduled by rAF getting // sent after the pinch end. window.requestAnimationFrame(() => { this.viewport_.pinchZoomEnd(e); }); }, /** * @private * A callback that's called when the start of a pinch zoom is detected. * @param {!Object} e the pinch event. */ onPinchStart_: function(e) { // We also use rAF for pinch start, so that if there is a pinch end event // scheduled by rAF, this pinch start will be sent after. window.requestAnimationFrame(() => { this.viewport_.pinchZoomStart(e); }); }, /** * @private * A callback that's called after the viewport changes. */ viewportChanged_: function() { if (!this.documentDimensions_) return; // 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; // 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 right, so this is not necessary. if (!isRTL()) { 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'; // Update the page indicator. var visiblePage = this.viewport_.getMostVisiblePage(); if (this.toolbar_) this.toolbar_.pageNo = visiblePage + 1; // TODO(raymes): Give pageIndicator_ the same API as toolbar_. 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 '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.isUserInitiatedEvent_ = false; this.zoomToolbar_.forceFit(FittingType.FIT_TO_PAGE); this.isUserInitiatedEvent_ = true; } // 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.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/ms). This is * intended to be high enough that a fast flick of the mouse is required to * reach it. */ var SHOW_VELOCITY = 10; /** 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; /** * @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. * @param {Window} window Window to test against. * @return {boolean} True if the mouse is close to the bottom-right of the * screen. */ function isMouseNearSideToolbar(e, window) { var atSide = e.x > window.innerWidth - SIDE_TOOLBAR_REVEAL_DISTANCE_RIGHT; if (isRTL()) 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.lastMovementTimestamp = null; 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.window_); 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_ || this.isHighVelocityMouseMove_(e) || touchInteractionActive) { if (this.sideToolbarAllowedOnly_) this.zoomToolbar_.show(); else this.showToolbars(); } this.hideToolbarsAfterTimeout(); }, /** * 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. * @private */ isHighVelocityMouseMove_: function(e) { if (e.type == 'mousemove') { if (this.lastMovementTimestamp == null) { this.lastMovementTimestamp = this.getCurrentTimestamp_(); } else { var movement = Math.sqrt(e.movementX * e.movementX + e.movementY * e.movementY); var newTime = this.getCurrentTimestamp_(); var interval = newTime - this.lastMovementTimestamp; this.lastMovementTimestamp = newTime; if (interval != 0) return movement / interval > SHOW_VELOCITY; } } return false; }, /** * Wrapper around Date.now() to make it easily replaceable for testing. * @return {number} * @private */ getCurrentTimestamp_: function() { return Date.now(); }, /** * 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(() => { this.sideToolbarAllowedOnlyTimer_ = null; }, 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 2017 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'; /** * Enumeration of page fitting types. * @enum {string} */ var FittingType = { NONE: 'none', FIT_TO_PAGE: 'fit-to-page', FIT_TO_WIDTH: 'fit-to-width', FIT_TO_HEIGHT: 'fit-to-height' }; // 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)); } /** * Computes vector between two points. * @param {!Object} p1 The first point. * @param {!Object} p2 The second point. * @return {!Object} The vector. */ function vectorDelta(p1, p2) { return {x: p2.x - p1.x, y: p2.y - p1.y}; } function frameToPluginCoordinate(coordinateInFrame) { var container = $('plugin'); return { x: coordinateInFrame.x - container.getBoundingClientRect().left, y: coordinateInFrame.y - container.getBoundingClientRect().top }; } /** * 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 {Function} setUserInitiatedCallback is run to indicate whether a zoom * event is user initiated. * @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, setUserInitiatedCallback, scrollbarWidth, defaultZoom, topToolbarHeight) { this.window_ = window; this.sizer_ = sizer; this.viewportChangedCallback_ = viewportChangedCallback; this.beforeZoomCallback_ = beforeZoomCallback; this.afterZoomCallback_ = afterZoomCallback; this.setUserInitiatedCallback_ = setUserInitiatedCallback; this.allowedToChangeZoom_ = false; this.internalZoom_ = 1; this.zoomManager_ = new InactiveZoomManager(this, 1); this.documentDimensions_ = null; this.pageDimensions_ = []; this.scrollbarWidth_ = scrollbarWidth; this.fittingType_ = FittingType.NONE; this.defaultZoom_ = defaultZoom; this.topToolbarHeight_ = topToolbarHeight; this.prevScale_ = 1; this.pinchPhase_ = Viewport.PinchPhase.PINCH_NONE; this.pinchPanVector_ = null; this.pinchCenter_ = null; this.firstPinchCenterInFrame_ = null; window.addEventListener('scroll', this.updateViewport_.bind(this)); window.addEventListener('resize', this.resizeWrapper_.bind(this)); } /** * Enumeration of pinch states. * This should match PinchPhase enum in pdf/out_of_process_instance.h * @enum {number} */ Viewport.PinchPhase = { PINCH_NONE: 0, PINCH_START: 1, PINCH_UPDATE_ZOOM_OUT: 2, PINCH_UPDATE_ZOOM_IN: 3, PINCH_END: 4 }; /** * 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 lists in * components/ui/zoom/page_zoom_constants.h and * chrome/browser/resources/settings/appearance_page/appearance_page.js */ Viewport.ZOOM_FACTORS = [ 0.25, 1 / 3, 0.5, 2 / 3, 0.75, 0.8, 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] }; /** * Clamps the zoom factor (or page scale factor) to be within the limits. * @param {number} factor The zoom/scale factor. * @return {number} The factor clamped within the limits. */ Viewport.clampZoom = function(factor) { return Math.max( Viewport.ZOOM_FACTOR_RANGE.min, Math.min(factor, Viewport.ZOOM_FACTOR_RANGE.max)); }; /** * 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 browser window size changes. */ resizeWrapper_: function() { this.setUserInitiatedCallback_(false); this.resize_(); this.setUserInitiatedCallback_(true); }, /** * @private * Called when the viewport size changes. */ resize_: function() { if (this.fittingType_ == FittingType.FIT_TO_PAGE) this.fitToPageInternal_(false); else if (this.fittingType_ == FittingType.FIT_TO_WIDTH) this.fitToWidth(); else if (this.fittingType_ == FittingType.FIT_TO_HEIGHT) this.fitToHeightInternal_(false); 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.zoomManager_.applyBrowserZoom(this.internalZoom_); }, /** * Set the zoom manager. * @type {ZoomManager} manager the zoom manager to set. */ set zoomManager(manager) { this.zoomManager_ = manager; }, /** * @type {Viewport.PinchPhase} The phase of the current pinch gesture for * the viewport. */ get pinchPhase() { return this.pinchPhase_; }, /** * @type {Object} The panning caused by the current pinch gesture (as * the deltas of the x and y coordinates). */ get pinchPanVector() { return this.pinchPanVector_; }, /** * @type {Object} The coordinates of the center of the current pinch gesture. */ get pinchCenter() { return this.pinchCenter_; }, /** * @private * @param {function} f Function to wrap * 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.internalZoom_ = newZoom; this.contentSizeChanged_(); // Scroll to the scaled scroll position. this.position = { x: currentScrollPos.x * this.zoom, y: currentScrollPos.y * this.zoom }; }, /** * @private * Sets the zoom of the viewport. * Same as setZoomInternal_ but for pinch zoom we have some more operations. * @param {number} scaleDelta The zoom delta. * @param {!Object} center The pinch center in content coordinates. */ setPinchZoomInternal_: function(scaleDelta, center) { assert( this.allowedToChangeZoom_, 'Called Viewport.setPinchZoomInternal_ without calling ' + 'Viewport.mightZoom_.'); this.internalZoom_ = Viewport.clampZoom(this.internalZoom_ * scaleDelta); var newCenterInContent = this.frameToContent(center); var delta = { x: (newCenterInContent.x - this.oldCenterInContent.x), y: (newCenterInContent.y - this.oldCenterInContent.y) }; // Record the scroll position (relative to the pinch center). var currentScrollPos = { x: this.position.x - delta.x * this.zoom, y: this.position.y - delta.y * this.zoom }; this.contentSizeChanged_(); // Scroll to the scaled scroll position. this.position = {x: currentScrollPos.x, y: currentScrollPos.y}; }, /** * @private * Converts a point from frame to content coordinates. * @param {!Object} framePoint The frame coordinates. * @return {!Object} The content coordinates. */ frameToContent: function(framePoint) { // TODO(mcnee) Add a helper Point class to avoid duplicating operations // on plain {x,y} objects. return { x: (framePoint.x + this.position.x) / this.zoom, y: (framePoint.y + this.position.y) / this.zoom }; }, /** * Sets the zoom to the given zoom level. * @param {number} newZoom the zoom level to zoom to. */ setZoom: function(newZoom) { this.fittingType_ = FittingType.NONE; this.mightZoom_(() => { this.setZoomInternal_(Viewport.clampZoom(newZoom)); this.updateViewport_(); }); }, /** * Gets notified of the browser zoom changing seperately from the * internal zoom. * @param {number} oldBrowserZoom the previous value of the browser zoom. */ updateZoomFromBrowserChange: function(oldBrowserZoom) { this.mightZoom_(() => { // Record the scroll position (relative to the top-left of the window). var oldZoom = oldBrowserZoom * this.internalZoom_; var currentScrollPos = { x: this.position.x / oldZoom, y: this.position.y / oldZoom }; this.contentSizeChanged_(); // Scroll to the scaled scroll position. this.position = { x: currentScrollPos.x * this.zoom, y: currentScrollPos.y * this.zoom }; this.updateViewport_(); }); }, /** * @type {number} the width of scrollbars in the viewport in pixels. */ get scrollbarWidth() { return this.scrollbarWidth_; }, /** * @type {FittingType} the fitting type the viewport is currently in. */ get fittingType() { return this.fittingType_; }, /** * @private * @param {number} y the y-coordinate to get the page at. * @return {number} 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; 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 {number} 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, fit-to-width or fit-to-height. * * At least one of {fitWidth, fitHeight} must be true. * * @param {Object} pageDimensions the dimensions of a given page in px. * @param {boolean} fitWidth a bool indicating whether the whole width of the * page needs to be in the viewport. * @param {boolean} fitHeight a bool indicating whether the whole height of * the page needs to be in the viewport. * @return {number} the internal zoom to set */ computeFittingZoom_: function(pageDimensions, fitWidth, fitHeight) { assert( fitWidth || fitHeight, 'Invalid parameters. At least one of fitWidth and fitHeight must be ' + 'true.'); // First compute the zoom without scrollbars. var zoom = this.computeFittingZoomGivenDimensions_( fitWidth, fitHeight, this.window_.innerWidth, this.window_.innerHeight, pageDimensions.width, pageDimensions.height); // 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. zoom = this.computeFittingZoomGivenDimensions_( fitWidth, fitHeight, windowWithScrollbars.width, windowWithScrollbars.height, pageDimensions.width, pageDimensions.height); return this.zoomManager_.internalZoomComponent(zoom); }, /** * @private * Compute a zoom level given the dimensions to fit and the actual numbers * in those dimensions. * * @param {boolean} fitWidth make sure the page width is totally contained in * the window. * @param {boolean} fitHeight make sure the page height is totally contained * in the window. * @param {number} windowWidth the width of the window in px. * @param {number} windowHeight the height of the window in px. * @param {number} pageWidth the width of the page in px. * @param {number} pageHeight the height of the page in px. * @return {number} the internal zoom to set */ computeFittingZoomGivenDimensions_: function( fitWidth, fitHeight, windowWidth, windowHeight, pageWidth, pageHeight) { // Assumes at least one of {fitWidth, fitHeight} is set. var zoomWidth; var zoomHeight; if (fitWidth) zoomWidth = windowWidth / pageWidth; if (fitHeight) zoomHeight = windowHeight / pageHeight; if (!fitWidth && fitHeight) return zoomHeight; if (fitWidth && !fitHeight) return zoomWidth; // Assume fitWidth && fitHeight return Math.min(zoomWidth, zoomHeight); }, /** * Zoom the viewport so that the page width consumes the entire viewport. */ fitToWidth: function() { this.mightZoom_(() => { this.fittingType_ = 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, false)); this.updateViewport_(); }); }, /** * @private * Zoom the viewport so that the page height 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. */ fitToHeightInternal_: function(scrollToTopOfPage) { this.mightZoom_(() => { this.fittingType_ = FittingType.FIT_TO_HEIGHT; if (!this.documentDimensions_) return; var page = this.getMostVisiblePage(); // When computing fit-to-height, the maximum height of the current page // is used. var dimensions = { width: 0, height: this.pageDimensions_[page].height, }; this.setZoomInternal_(this.computeFittingZoom_(dimensions, false, true)); if (scrollToTopOfPage) { this.position = {x: 0, y: this.pageDimensions_[page].y * this.zoom}; } this.updateViewport_(); }); }, /** * Zoom the viewport so that the page height consumes the entire viewport. */ fitToHeight: function() { this.fitToHeightInternal_(true); }, /** * @private * Zoom the viewport so that a page consumes as much as possible of the it. * @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_(() => { this.fittingType_ = 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, true, true)); if (scrollToTopOfPage) { this.position = {x: 0, y: this.pageDimensions_[page].y * this.zoom}; } this.updateViewport_(); }); }, /** * 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_(() => { this.fittingType_ = FittingType.NONE; var nextZoom = Viewport.ZOOM_FACTORS[0]; for (var i = 0; i < Viewport.ZOOM_FACTORS.length; i++) { if (Viewport.ZOOM_FACTORS[i] < this.internalZoom_) nextZoom = Viewport.ZOOM_FACTORS[i]; } this.setZoomInternal_(nextZoom); this.updateViewport_(); }); }, /** * Zoom in to the next predefined zoom level. */ zoomIn: function() { this.mightZoom_(() => { this.fittingType_ = 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.internalZoom_) nextZoom = Viewport.ZOOM_FACTORS[i]; } this.setZoomInternal_(nextZoom); this.updateViewport_(); }); }, /** * Pinch zoom event handler. * @param {!Object} e The pinch event. */ pinchZoom: function(e) { this.mightZoom_(() => { this.pinchPhase_ = e.direction == 'out' ? Viewport.PinchPhase.PINCH_UPDATE_ZOOM_OUT : Viewport.PinchPhase.PINCH_UPDATE_ZOOM_IN; var scaleDelta = e.startScaleRatio / this.prevScale_; this.pinchPanVector_ = vectorDelta(e.center, this.firstPinchCenterInFrame_); var needsScrollbars = this.documentNeedsScrollbars_(this.zoomManager_.applyBrowserZoom( Viewport.clampZoom(this.internalZoom_ * scaleDelta))); this.pinchCenter_ = e.center; // If there's no horizontal scrolling, keep the content centered so the // user can't zoom in on the non-content area. // TODO(mcnee) Investigate other ways of scaling when we don't have // horizontal scrolling. We want to keep the document centered, // but this causes a potentially awkward transition when we start // using the gesture center. if (!needsScrollbars.horizontal) { this.pinchCenter_ = { x: this.window_.innerWidth / 2, y: this.window_.innerHeight / 2 }; } else if (this.keepContentCentered_) { this.oldCenterInContent = this.frameToContent(frameToPluginCoordinate(e.center)); this.keepContentCentered_ = false; } this.setPinchZoomInternal_(scaleDelta, frameToPluginCoordinate(e.center)); this.updateViewport_(); this.prevScale_ = e.startScaleRatio; }); }, pinchZoomStart: function(e) { this.pinchPhase_ = Viewport.PinchPhase.PINCH_START; this.prevScale_ = 1; this.oldCenterInContent = this.frameToContent(frameToPluginCoordinate(e.center)); var needsScrollbars = this.documentNeedsScrollbars_(this.zoom); this.keepContentCentered_ = !needsScrollbars.horizontal; // We keep track of begining of the pinch. // By doing so we will be able to compute the pan distance. this.firstPinchCenterInFrame_ = e.center; }, pinchZoomEnd: function(e) { this.mightZoom_(() => { this.pinchPhase_ = Viewport.PinchPhase.PINCH_END; var scaleDelta = e.startScaleRatio / this.prevScale_; this.pinchCenter_ = e.center; this.setPinchZoomInternal_(scaleDelta, frameToPluginCoordinate(e.center)); this.updateViewport_(); }); this.pinchPhase_ = Viewport.PinchPhase.PINCH_NONE; this.pinchPanVector_ = null; this.pinchCenter_ = null; this.firstPinchCenterInFrame_ = null; }, /** * Go to the given page index. * @param {number} page the index of the page to go to. zero-based. */ goToPage: function(page) { this.goToPageAndXY(page, 0, 0); }, /** * Go to the given y position in the given page index. * @param {number} page the index of the page to go to. zero-based. * @param {number} x the x position in the page to go to. * @param {number} y the y position in the page to go to. */ goToPageAndXY: function(page, x, y) { this.mightZoom_(() => { 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 or fit to height mode, scroll above the // page by |this.topToolbarHeight_| so that the toolbar isn't covering it // initially. if (!this.isPagedMode()) toolbarOffset = this.topToolbarHeight_; this.position = { x: (dimensions.x + x) * this.zoom, y: (dimensions.y + y) * this.zoom - toolbarOffset }; this.updateViewport_(); }); }, /** * Set the dimensions of the document. * @param {Object} documentDimensions the dimensions of the document */ setDocumentDimensions: function(documentDimensions) { this.mightZoom_(() => { 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, false))); this.position = {x: 0, y: -this.topToolbarHeight_}; } this.contentSizeChanged_(); this.resize_(); }); }, /** * 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 }; }, /** * Check if the current fitting type is a paged mode. * * In a paged mode, page up and page down scroll to the top of the * previous/next page and part of the page is under the toolbar. * * @return {boolean} Whether the current fitting type is a paged mode. */ isPagedMode: function(page) { return ( this.fittingType_ == FittingType.FIT_TO_PAGE || this.fittingType_ == FittingType.FIT_TO_HEIGHT); } }; // 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 OpenPDFParamsParser; (function() { '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 {!Function} getNamedDestinationsFunction The function called to fetch * the page number for a named destination. * @constructor */ OpenPDFParamsParser = function(getNamedDestinationsFunction) { this.outstandingRequests_ = []; this.getNamedDestinationsFunction_ = getNamedDestinationsFunction; }; OpenPDFParamsParser.prototype = { /** * @private * Parse zoom parameter of open PDF parameters. The PDF should be opened at * the specified zoom level. * @param {string} paramValue zoom value. * @return {Object} Map with zoom parameters (zoom and position). */ parseZoomParam_: function(paramValue) { 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) { return {'zoom': zoomFactor}; } // Handle #zoom=scale,left,top. var position = { x: parseFloat(paramValueSplit[1]), y: parseFloat(paramValueSplit[2]) }; return {'position': position, 'zoom': zoomFactor}; }, /** * @private * Parse view parameter of open PDF parameters. The PDF should be opened at * the specified fitting type mode and position. * @param {string} paramValue view value. * @return {Object} Map with view parameters (view and viewPosition). */ parseViewParam_: function(paramValue) { var viewModeComponents = paramValue.toLowerCase().split(','); if (viewModeComponents.length < 1) return {}; var params = {}; var viewMode = viewModeComponents[0]; var acceptsPositionParam; if (viewMode === 'fit') { params['view'] = FittingType.FIT_TO_PAGE; acceptsPositionParam = false; } else if (viewMode === 'fith') { params['view'] = FittingType.FIT_TO_WIDTH; acceptsPositionParam = true; } else if (viewMode === 'fitv') { params['view'] = FittingType.FIT_TO_HEIGHT; acceptsPositionParam = true; } if (!acceptsPositionParam || viewModeComponents.length < 2) return params; var position = parseFloat(viewModeComponents[1]); if (!isNaN(position)) params['viewPosition'] = position; return params; }, /** * Parse the parameters encoded in the fragment of a URL into a dictionary. * @private * @param {string} url to parse * @return {Object} Key-value pairs of URL parameters */ parseUrlParams_: function(url) { var params = {}; var paramIndex = url.search('#'); if (paramIndex == -1) return params; 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. params['nameddest'] = paramTokens[0]; return params; } for (var i = 0; i < paramTokens.length; ++i) { var keyValueSplit = paramTokens[i].split('='); if (keyValueSplit.length != 2) continue; params[keyValueSplit[0]] = keyValueSplit[1]; } return params; }, /** * Parse PDF url parameters used for controlling the state of UI. These need * to be available when the UI is being initialized, rather than when the PDF * is finished loading. * @param {string} url that needs to be parsed. * @return {Object} parsed url parameters. */ getUiUrlParams: function(url) { var params = this.parseUrlParams_(url); var uiParams = {toolbar: true}; if ('toolbar' in params && params['toolbar'] == 0) uiParams.toolbar = false; return uiParams; }, /** * 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 params = {}; params['url'] = url; var urlParams = this.parseUrlParams_(url); if ('page' in urlParams) { // |pageNumber| is 1-based, but goToPage() take a zero-based page number. var pageNumber = parseInt(urlParams['page'], 10); if (!isNaN(pageNumber) && pageNumber > 0) params['page'] = pageNumber - 1; } if ('view' in urlParams) Object.assign(params, this.parseViewParam_(urlParams['view'])); if ('zoom' in urlParams) Object.assign(params, this.parseZoomParam_(urlParams['zoom'])); if (params.page === undefined && 'nameddest' in urlParams) { this.outstandingRequests_.push({callback: callback, params: params}); this.getNamedDestinationsFunction_(urlParams['nameddest']); } else { callback(params); } }, /** * 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.params.page = pageNumber; outstandingRequest.callback(outstandingRequest.params); }, }; }()); // 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 NavigatorDelegate for calling browser-specific functions to * do the actual navigating. * @param {number} tabId The tab ID of the PDF viewer or -1 if the viewer is * not displayed in a tab. * @constructor */ function NavigatorDelegate(tabId) { this.tabId_ = tabId; } /** * 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 {Object} navigatorDelegate The object with callback functions that * get called when navigation happens in the current tab, a new tab, * and a new window. * @constructor */ function Navigator(originalUrl, viewport, paramsParser, navigatorDelegate) { this.originalUrl_ = originalUrl; this.viewport_ = viewport; this.paramsParser_ = paramsParser; this.navigatorDelegate_ = navigatorDelegate; } NavigatorDelegate.prototype = { /** * @public * Called when navigation should happen in the current tab. * @param {string} url The url to be opened in the current tab. */ navigateInCurrentTab: function(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 && this.tabId_ != -1) chrome.tabs.update(this.tabId_, {url: url}); else window.location.href = url; }, /** * @public * Called when navigation should happen in the new tab. * @param {string} url The url to be opened in the new tab. * @param {boolean} active Indicates if the new tab should be the active tab. */ navigateInNewTab: function(url, active) { // 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, active: active}); else window.open(url); }, /** * @public * Called when navigation should happen in the new window. * @param {string} url The url to be opened in the new window. */ navigateInNewWindow: function(url) { // Prefer the windows API because it guarantees we can just open a new // window. window.open with '_blank' argument doesn't have this guarantee. if (chrome.windows) chrome.windows.create({url: url}); else window.open(url, '_blank'); } }; /** * Represents options when navigating to a new url. C++ counterpart of * the enum is in ui/base/window_open_disposition.h. This enum represents * the only values that are passed from Plugin. * @enum {number} */ Navigator.WindowOpenDisposition = { CURRENT_TAB: 1, NEW_FOREGROUND_TAB: 3, NEW_BACKGROUND_TAB: 4, NEW_WINDOW: 6, SAVE_TO_DISK: 7 }; Navigator.prototype = { /** * 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 {number} disposition The window open disposition when * navigating to the new URL. */ navigate: function(url, disposition) { 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; switch (disposition) { case Navigator.WindowOpenDisposition.CURRENT_TAB: this.paramsParser_.getViewportFromUrlParams( url, this.onViewportReceived_.bind(this)); break; case Navigator.WindowOpenDisposition.NEW_BACKGROUND_TAB: this.navigatorDelegate_.navigateInNewTab(url, false); break; case Navigator.WindowOpenDisposition.NEW_FOREGROUND_TAB: this.navigatorDelegate_.navigateInNewTab(url, true); break; case Navigator.WindowOpenDisposition.NEW_WINDOW: this.navigatorDelegate_.navigateInNewWindow(url); break; case Navigator.WindowOpenDisposition.SAVE_TO_DISK: // TODO(jaepark): Alt + left clicking a link in PDF should // download the link. this.paramsParser_.getViewportFromUrlParams( url, this.onViewportReceived_.bind(this)); break; default: break; } }, /** * @private * Called when the viewport position is received. * @param {Object} viewportPosition Dictionary containing the viewport * position. */ onViewportReceived_: function(viewportPosition) { var originalUrl = this.originalUrl_; var hashIndex = originalUrl.search('#'); if (hashIndex != -1) originalUrl = originalUrl.substring(0, hashIndex); var newUrl = viewportPosition.url; hashIndex = newUrl.search('#'); if (hashIndex != -1) newUrl = newUrl.substring(0, hashIndex); var pageNumber = viewportPosition.page; if (pageNumber != undefined && originalUrl == newUrl) this.viewport_.goToPage(pageNumber); else this.navigatorDelegate_.navigateInCurrentTab(viewportPosition.url); }, /** * @private * Checks if the URL starts with a scheme and is not just a scheme. * @param {string} url The input URL * @return {boolean} Whether the url is valid. */ isValidUrl_: function(url) { // Make sure |url| starts with a valid scheme. if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('ftp://') && !url.startsWith('file://') && !url.startsWith('mailto:')) { return false; } // Navigations to file:-URLs are only allowed from file:-URLs. if (url.startsWith('file:') && !this.originalUrl_.startsWith('file:')) 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} url 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. * @constructor */ 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', true, true); 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. * @enum {string} */ 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. * @constructor */ function PDFScriptingAPI(window, plugin) { this.loadState_ = LoadState.LOADING; this.pendingScriptingMessages_ = []; this.setPlugin(plugin); window.addEventListener('message', event => { if (event.origin != 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai' && event.origin != 'chrome://print') { console.error( 'Received message that was not from the extension: ' + event); return; } switch (event.data.type) { case 'viewport': /** * @type {{ * pageX: number, * pageY: number, * pageWidth: number, * viewportWidth: number, * viewportHeight: number * }} */ var viewportData = event.data; if (this.viewportChangedCallback_) this.viewportChangedCallback_( viewportData.pageX, viewportData.pageY, viewportData.pageWidth, viewportData.viewportWidth, viewportData.viewportHeight); break; case 'documentLoaded': var data = /** @type {{load_state: LoadState}} */ (event.data); this.loadState_ = data.load_state; if (this.loadCallback_) this.loadCallback_(this.loadState_ == LoadState.SUCCESS); break; case 'getSelectedTextReply': var data = /** @type {{selectedText: string}} */ (event.data); if (this.selectedTextCallback_) { this.selectedTextCallback_(data.selectedText); this.selectedTextCallback_ = null; } break; case 'sendKeyEvent': if (this.keyEventCallback_) this.keyEventCallback_(DeserializeKeyEvent(event.data.keyEvent)); break; } }, 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}); }, /** * 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, null); var iframe = assertInstanceof( window.document.createElement('iframe'), HTMLIFrameElement); iframe.setAttribute('src', 'pdf_preview.html?' + src); // Prevent the frame from being tab-focusable. iframe.setAttribute('tabindex', '-1'); 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'; /** * Abstract parent of classes that manage updating the browser * with zoom changes and/or updating the viewer's zoom when * the browser zoom changes. */ class ZoomManager { /** * Constructs a ZoomManager. * @param {!Viewport} viewport A Viewport for which to manage zoom. * @param {number} initialZoom The initial browser zoom level. */ constructor(viewport, initialZoom) { if (this.constructor === ZoomManager) { throw new TypeError('Instantiated abstract class: ZoomManager'); } this.viewport_ = viewport; this.browserZoom_ = initialZoom; } /** * Creates the appropriate kind of zoom manager given the zoom behavior. * @param {BrowserApi.ZoomBehavior} zoomBehavior How to manage zoom. * @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. */ static create(zoomBehavior, viewport, setBrowserZoomFunction, initialZoom) { switch (zoomBehavior) { case BrowserApi.ZoomBehavior.MANAGE: return new ActiveZoomManager( viewport, setBrowserZoomFunction, initialZoom); case BrowserApi.ZoomBehavior.PROPAGATE_PARENT: return new EmbeddedZoomManager(viewport, initialZoom); default: return new InactiveZoomManager(viewport, initialZoom); } } /** * Invoked when a browser-initiated zoom-level change occurs. * @param {number} newZoom the zoom level to zoom to. */ onBrowserZoomChange(newZoom) {} /** * Invoked when an extension-initiated zoom-level change occurs. */ onPdfZoomChange() {} /** * Combines the internal pdf zoom and the browser zoom to * produce the total zoom level for the viewer. * @param {number} internalZoom the zoom level internal to the viewer. * @return {number} the total zoom level. */ applyBrowserZoom(internalZoom) { return this.browserZoom_ * internalZoom; } /** * Given a zoom level, return the internal zoom level needed to * produce that zoom level. * @param {number} totalZoom the total zoom level. * @return {number} the zoom level internal to the viewer. */ internalZoomComponent(totalZoom) { return totalZoom / this.browserZoom_; } /** * 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; } } /** * InactiveZoomManager has no control over the browser's zoom * and does not respond to browser zoom changes. */ class InactiveZoomManager extends ZoomManager {} /** * ActiveZoomManager controls the browser's zoom. */ class ActiveZoomManager extends ZoomManager { /** * Constructs a ActiveZoomManager. * @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) { super(viewport, initialZoom); this.setBrowserZoomFunction_ = setBrowserZoomFunction; 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(() => { 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(); }); } /** * Combines the internal pdf zoom and the browser zoom to * produce the total zoom level for the viewer. * @param {number} internalZoom the zoom level internal to the viewer. * @return {number} the total zoom level. */ applyBrowserZoom(internalZoom) { // The internal zoom and browser zoom are changed together, so the // browser zoom is already applied. return internalZoom; } /** * Given a zoom level, return the internal zoom level needed to * produce that zoom level. * @param {number} totalZoom the total zoom level. * @return {number} the zoom level internal to the viewer. */ internalZoomComponent(totalZoom) { // The internal zoom and browser zoom are changed together, so the // internal zoom is the total zoom. return totalZoom; } } /** * This EmbeddedZoomManager responds to changes in the browser zoom, * but does not control the browser zoom. */ class EmbeddedZoomManager extends ZoomManager { /** * Invoked when a browser-initiated zoom-level change occurs. * @param {number} newZoom the new browser zoom level. */ onBrowserZoomChange(newZoom) { let oldZoom = this.browserZoom_; this.browserZoom_ = newZoom; this.viewport_.updateZoomFromBrowserChange(oldZoom); } } // Copyright 2016 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 listens for touch events and produces events when these * touches form gestures (e.g. pinching). */ class GestureDetector { /** * Constructs a GestureDetector. * @param {!Element} element The element to monitor for touch gestures. */ constructor(element) { /** @private {!Element} */ this.element_ = element; this.element_.addEventListener( 'touchstart', /** @type {function(!Event)} */ (this.onTouchStart_.bind(this)), {passive: true}); let boundOnTouch = /** @type {function(!Event)} */ (this.onTouch_.bind(this)); this.element_.addEventListener('touchmove', boundOnTouch, {passive: false}); this.element_.addEventListener('touchend', boundOnTouch, {passive: true}); this.element_.addEventListener( 'touchcancel', boundOnTouch, {passive: true}); this.element_.addEventListener( 'wheel', /** @type {function(!Event)} */ (this.onWheel_.bind(this)), {passive: false}); this.pinchStartEvent_ = null; this.lastTouchTouchesCount_ = 0; /** @private {?TouchEvent} */ this.lastEvent_ = null; /** * The scale relative to the start of the pinch when handling ctrl-wheels. * null when there is no ongoing pinch. * @private {?number} */ this.accumulatedWheelScale_ = null; /** * A timeout ID from setTimeout used for sending the pinchend event when * handling ctrl-wheels. * @private {?number} */ this.wheelEndTimeout_ = null; /** @private {!Map>} */ this.listeners_ = new Map([['pinchstart', []], ['pinchupdate', []], ['pinchend', []]]); } /** * Add a |listener| to be notified of |type| events. * @param {string} type The event type to be notified for. * @param {!Function} listener The callback. */ addEventListener(type, listener) { if (this.listeners_.has(type)) { this.listeners_.get(type).push(listener); } } /** * Returns true if the last touch start was a two finger touch. * @return {boolean} True if the last touch start was a two finger touch. */ wasTwoFingerTouch() { return this.lastTouchTouchesCount_ == 2; } /** * Call the relevant listeners with the given |pinchEvent|. * @private * @param {!Object} pinchEvent The event to notify the listeners of. */ notify_(pinchEvent) { let listeners = this.listeners_.get(pinchEvent.type); for (let l of listeners) l(pinchEvent); } /** * The callback for touchstart events on the element. * @private * @param {!TouchEvent} event Touch event on the element. */ onTouchStart_(event) { this.lastTouchTouchesCount_ = event.touches.length; if (!this.wasTwoFingerTouch()) return; this.pinchStartEvent_ = event; this.lastEvent_ = event; this.notify_({type: 'pinchstart', center: GestureDetector.center_(event)}); } /** * The callback for touch move, end, and cancel events on the element. * @private * @param {!TouchEvent} event Touch event on the element. */ onTouch_(event) { if (!this.pinchStartEvent_) return; let lastEvent = /** @type {!TouchEvent} */ (this.lastEvent_); // Check if the pinch ends with the current event. if (event.touches.length < 2 || lastEvent.touches.length !== event.touches.length) { let startScaleRatio = GestureDetector.pinchScaleRatio_(lastEvent, this.pinchStartEvent_); let center = GestureDetector.center_(lastEvent); let endEvent = { type: 'pinchend', startScaleRatio: startScaleRatio, center: center }; this.pinchStartEvent_ = null; this.lastEvent_ = null; this.notify_(endEvent); return; } // We must preventDefault two finger touchmoves. By doing so native // pinch-zoom does not interfere with our way of handling the event. event.preventDefault(); let scaleRatio = GestureDetector.pinchScaleRatio_(event, lastEvent); let startScaleRatio = GestureDetector.pinchScaleRatio_(event, this.pinchStartEvent_); let center = GestureDetector.center_(event); this.notify_({ type: 'pinchupdate', scaleRatio: scaleRatio, direction: scaleRatio > 1.0 ? 'in' : 'out', startScaleRatio: startScaleRatio, center: center }); this.lastEvent_ = event; } /** * The callback for wheel events on the element. * @private * @param {!WheelEvent} event Wheel event on the element. */ onWheel_(event) { // We handle ctrl-wheels to invoke our own pinch zoom. On Mac, synthetic // ctrl-wheels are created from trackpad pinches. We handle these ourselves // to prevent the browser's native pinch zoom. We also use our pinch // zooming mechanism for handling non-synthetic ctrl-wheels. This allows us // to anchor the zoom around the mouse position instead of the scroll // position. if (!event.ctrlKey) return; event.preventDefault(); let wheelScale = Math.exp(-event.deltaY / 100); // Clamp scale changes from the wheel event as they can be // quite dramatic for non-synthetic ctrl-wheels. let scale = Math.min(1.25, Math.max(0.75, wheelScale)); let position = {x: event.clientX, y: event.clientY}; if (this.accumulatedWheelScale_ == null) { this.accumulatedWheelScale_ = 1.0; this.notify_({type: 'pinchstart', center: position}); } this.accumulatedWheelScale_ *= scale; this.notify_({ type: 'pinchupdate', scaleRatio: scale, direction: scale > 1.0 ? 'in' : 'out', startScaleRatio: this.accumulatedWheelScale_, center: position }); // We don't get any phase information for the ctrl-wheels, so we don't know // when the gesture ends. We'll just use a timeout to send the pinch end // event a short time after the last ctrl-wheel we see. if (this.wheelEndTimeout_ != null) { window.clearTimeout(this.wheelEndTimeout_); this.wheelEndTimeout_ = null; } let gestureEndDelayMs = 100; let endEvent = { type: 'pinchend', startScaleRatio: this.accumulatedWheelScale_, center: position }; this.wheelEndTimeout_ = window.setTimeout(function(endEvent) { this.notify_(endEvent); this.wheelEndTimeout_ = null; this.accumulatedWheelScale_ = null; }.bind(this), gestureEndDelayMs, endEvent); } /** * Computes the change in scale between this touch event * and a previous one. * @private * @param {!TouchEvent} event Latest touch event on the element. * @param {!TouchEvent} prevEvent A previous touch event on the element. * @return {?number} The ratio of the scale of this event and the * scale of the previous one. */ static pinchScaleRatio_(event, prevEvent) { let distance1 = GestureDetector.distance_(prevEvent); let distance2 = GestureDetector.distance_(event); return distance1 === 0 ? null : distance2 / distance1; } /** * Computes the distance between fingers. * @private * @param {!TouchEvent} event Touch event with at least 2 touch points. * @return {number} Distance between touch[0] and touch[1]. */ static distance_(event) { let touch1 = event.touches[0]; let touch2 = event.touches[1]; let dx = touch1.clientX - touch2.clientX; let dy = touch1.clientY - touch2.clientY; return Math.sqrt(dx * dx + dy * dy); } /** * Computes the midpoint between fingers. * @private * @param {!TouchEvent} event Touch event with at least 2 touch points. * @return {!Object} Midpoint between touch[0] and touch[1]. */ static center_(event) { let touch1 = event.touches[0]; let touch2 = event.touches[1]; return { x: (touch1.clientX + touch2.clientX) / 2, y: (touch1.clientY + touch2.clientY) / 2 }; } } // 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 { /** * @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 {BrowserApi.ZoomBehavior} zoomBehavior How to manage zoom. */ constructor(streamInfo, defaultZoom, initialZoom, zoomBehavior) { this.streamInfo_ = streamInfo; this.defaultZoom_ = defaultZoom; this.initialZoom_ = initialZoom; this.zoomBehavior_ = zoomBehavior; } /** * Returns a promise to a BrowserApi. * @param {!Object} streamInfo The stream object pointing to the data * contained in the PDF. * @param {BrowserApi.ZoomBehavior} zoomBehavior How to manage zoom. */ static create(streamInfo, zoomBehavior) { return Promise .all([lookupDefaultZoom(streamInfo), lookupInitialZoom(streamInfo)]) .then(function(zoomFactors) { return new BrowserApi( streamInfo, zoomFactors[0], zoomFactors[1], zoomBehavior); }); } /** * 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.zoomBehavior_ != BrowserApi.ZoomBehavior.MANAGE) return Promise.reject(new Error('Viewer does not manage browser zoom.')); return new Promise((resolve, reject) => { chrome.tabs.setZoom(this.streamInfo_.tabId, zoom, resolve); }); } /** * 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_; } /** * Returns how to manage the zoom. * @return {BrowserApi.ZoomBehavior} How to manage zoom. */ getZoomBehavior() { return this.zoomBehavior_; } /** * 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.zoomBehavior_ == BrowserApi.ZoomBehavior.MANAGE || this.zoomBehavior_ == BrowserApi.ZoomBehavior.PROPAGATE_PARENT)) return; chrome.tabs.onZoomChange.addListener(info => { var zoomChangeInfo = /** @type {{tabId: number, newZoomFactor: number}} */ (info); if (zoomChangeInfo.tabId != this.streamInfo_.tabId) return; listener(zoomChangeInfo.newZoomFactor); }); } } /** * Enumeration of ways to manage zoom changes. * @enum {number} */ BrowserApi.ZoomBehavior = { NONE: 0, MANAGE: 1, PROPAGATE_PARENT: 2 }; /** * 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 promises = []; let zoomBehavior = BrowserApi.ZoomBehavior.NONE; if (streamInfo.tabId != -1) { zoomBehavior = streamInfo.embedded ? BrowserApi.ZoomBehavior.PROPAGATE_PARENT : BrowserApi.ZoomBehavior.MANAGE; promises.push(new Promise(function(resolve) { chrome.tabs.get(streamInfo.tabId, resolve); }).then(function(tab) { if (tab) streamInfo.tabUrl = tab.url; })); } if (zoomBehavior == BrowserApi.ZoomBehavior.MANAGE) { promises.push(new Promise(function(resolve) { chrome.tabs.setZoomSettings( streamInfo.tabId, {mode: 'manual', scope: 'per-tab'}, resolve); })); } return Promise.all(promises).then(function() { return BrowserApi.create(streamInfo, zoomBehavior); }); }); } /** * 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 createBrowserApiForPrintPreview() { 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; streamInfo.tabUrl = tab.url; resolve(); }); }) .then(function() { return BrowserApi.create(streamInfo, BrowserApi.ZoomBehavior.NONE); }); } /** * 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 (location.origin === 'chrome://print') { return createBrowserApiForPrintPreview(); } 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 2017 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'; // Keep in sync with enums.xml. // Do not change the numeric values or reuse them since these numbers are // persisted to logs. const UserAction = { DOCUMENT_OPENED: 0, // Baseline to use as denominator for all formulas. ROTATE_FIRST: 1, ROTATE: 2, FIT_TO_WIDTH_FIRST: 3, FIT_TO_WIDTH: 4, FIT_TO_PAGE_FIRST: 5, FIT_TO_PAGE: 6, OPEN_BOOKMARKS_PANEL_FIRST: 7, OPEN_BOOKMARKS_PANEL: 8, FOLLOW_BOOKMARK_FIRST: 9, FOLLOW_BOOKMARK: 10, PAGE_SELECTOR_NAVIGATE_FIRST: 11, PAGE_SELECTOR_NAVIGATE: 12, NUMBER_OF_ACTIONS: 13 }; /** * Handles events specific to the PDF viewer and logs the corresponding metrics. * * @interface */ window.PDFMetrics = class { constructor() {} /** * Call when the document is first loaded. This event serves as denominator to * determine percentages of documents in which an action was taken as well as * average number of each action per document. */ onDocumentOpened() {} /** * Call when the document is rotated clockwise or counter-clockwise. */ onRotation() {} /** * Call when the zoom mode is changed to fit a FittingType. * @param {FittingType} fittingType the new FittingType. */ onFitTo(fittingType) {} /** * Call when the bookmarks panel is opened. */ onOpenBookmarksPanel() {} /** * Call when a bookmark is followed. */ onFollowBookmark() {} /** * Call when the page selection is used to navigate to another page. */ onPageSelectorNavigation() {} }; /** * Dummy implementation of PDFMetrics. * This is used in print preview mode to avoid bundling the actions in the PDF * viewer and the print preview in the same histogram. Also, metricsPrivate is * not available in print preview. * @implements {PDFMetrics} */ window.PDFMetricsDummy = class { constructor() {} /** @override */ onDocumentOpened() {} /** @override */ onRotation() {} /** @override */ onFitTo(fittingType) {} /** @override */ onOpenBookmarksPanel() {} /** @override */ onFollowBookmark() {} /** @override */ onPageSelectorNavigation() {} }; /** * Implementation of PDFMetrics that logs the corresponding metrics to UMA * through chrome.metricsPrivate. * @implements {PDFMetrics} */ window.PDFMetricsImpl = class { constructor() { /** * @private {Set} */ this.firstEventLogged_ = new Set(); /** * @private {Object} */ this.actionsMetric_ = { 'metricName': 'PDF.Actions', 'type': chrome.metricsPrivate.MetricTypeType.HISTOGRAM_LOG, 'min': 1, 'max': UserAction.NUMBER_OF_ACTIONS, 'buckets': UserAction.NUMBER_OF_ACTIONS + 1 }; } /** @override */ onDocumentOpened() { this.logOnlyFirstTime_(UserAction.DOCUMENT_OPENED); } /** @override */ onRotation() { this.logFirstAndTotal_(UserAction.ROTATE_FIRST, UserAction.ROTATE); } /** @override */ onFitTo(fittingType) { if (fittingType == FittingType.FIT_TO_PAGE) { this.logFirstAndTotal_( UserAction.FIT_TO_PAGE_FIRST, UserAction.FIT_TO_PAGE); } else if (fittingType == FittingType.FIT_TO_WIDTH) { this.logFirstAndTotal_( UserAction.FIT_TO_WIDTH_FIRST, UserAction.FIT_TO_WIDTH); } // There is no user action to do a fit-to-height, this only happens with // the open param "view=FitV". } /** @override */ onOpenBookmarksPanel() { this.logFirstAndTotal_( UserAction.OPEN_BOOKMARKS_PANEL_FIRST, UserAction.OPEN_BOOKMARKS_PANEL); } /** @override */ onFollowBookmark() { this.logFirstAndTotal_( UserAction.FOLLOW_BOOKMARK_FIRST, UserAction.FOLLOW_BOOKMARK); } /** @override */ onPageSelectorNavigation() { this.logFirstAndTotal_( UserAction.PAGE_SELECTOR_NAVIGATE_FIRST, UserAction.PAGE_SELECTOR_NAVIGATE); } /** * @private * Logs the "first" event code if it hasn't been logged by this instance yet * and also log the "total" event code. This distinction allows analyzing * both: * - in what percentage of documents each action was taken; * - how many times, on average, each action is taken on a document; * @param {number} firstEventCode event code for the "first" metric. * @return {number} totalEventCode event code for the "total" metric. */ logFirstAndTotal_(firstEventCode, totalEventCode) { this.log_(totalEventCode); this.logOnlyFirstTime_(firstEventCode); } /** * @private * Logs the given event code to chrome.metricsPrivate. * @param {number} eventCode event code to log. */ log_(eventCode) { chrome.metricsPrivate.recordValue(this.actionsMetric_, eventCode); } /** * @private * Logs the given event code. Subsequent calls of this method with the same * event code have no effect on the this PDFMetrics instance. * @param {number} eventCode event code to log. */ logOnlyFirstTime_(eventCode) { if (!this.firstEventLogged_.has(eventCode)) { this.log_(eventCode); this.firstEventLogged_.add(eventCode); } } }; window.PDFMetrics.UserAction = UserAction; }()); // 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. /** * The |title| is the text label displayed for the bookmark. * * The bookmark may point at a location in the PDF or a URI. * If it points at a location, |page| indicates which 0-based page it leads to. * Optionally, |y| is the y position in that page, in pixel coordinates. * If it points at an URI, |uri| is the target for that bookmark. * * |children| is an array of the |Bookmark|s that are below this in a table of * contents tree * structure. * @typedef {{ * title: string, * page: number, * y: number, * uri: string, * children: !Array * }} */ var Bookmark; (function() { /** Amount that each level of bookmarks is indented by (px). */ var BOOKMARK_INDENT = 20; Polymer({ is: 'viewer-bookmark', properties: { /** @type {Bookmark} */ 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')) { if (this.bookmark.hasOwnProperty('y')) { this.fire('change-page-and-xy', { page: this.bookmark.page, x: 0, y: this.bookmark.y, origin: 'bookmark' }); } else { this.fire( 'change-page', {page: this.bookmark.page, origin: 'bookmark'}); } } else if (this.bookmark.hasOwnProperty('uri')) { this.fire('navigate', {uri: this.bookmark.uri, newtab: true}); } }, 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 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: { reloadFn: Function, strings: Object, }, show: function() { /** @type {!CrDialogElement} */ (this.$.dialog).showModal(); }, reload: function() { if (this.reloadFn) this.reloadFn(); } }); // 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'} }, /** @type {number|undefined} */ timerId: undefined, /** @override */ ready: function() { var callback = this.fadeIn.bind(this, 2000); window.addEventListener('scroll', function() { requestAnimationFrame(callback); }); }, initialFadeIn: function() { this.fadeIn(6000); }, /** @param {number} displayTime */ fadeIn: function(displayTime) { var percent = window.scrollY / (document.scrollingElement.scrollHeight - document.documentElement.clientHeight); this.style.top = percent * (document.documentElement.clientHeight - this.offsetHeight) + 'px'; // this.style.opacity = 1; clearTimeout(this.timerId); this.timerId = setTimeout(() => { this.style.opacity = 0; this.timerId = undefined; }, 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. 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, 10); if (!isNaN(page) && page <= this.docLength && page > 0) this.fire('change-page', {page: page - 1, origin: 'pageselector'}); else this.$.input.value = this.pageNo; this.$.input.blur(); }, /** @private */ 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: { invalid: Boolean, strings: Object, }, get active() { return this.$.dialog.open; }, show: function() { this.$.dialog.showModal(); }, close: function() { if (this.active) this.$.dialog.close(); }, deny: function() { var password = /** @type {!PaperInputElement} */ (this.$.password); password.disabled = false; this.$.submit.disabled = false; this.invalid = true; password.focus(); password.inputElement.select(); }, submit: function() { var password = /** @type {!PaperInputElement} */ (this.$.password); if (password.value.length == 0) return; password.disabled = true; this.$.submit.disabled = true; this.fire('password-submitted', {password: password.value}); }, }); // 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: { /** * 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}, strings: Object, animationConfig: { value: function() { return { 'entry': { name: 'transform-animation', node: this, transformFrom: 'translateY(-100%)', transformTo: 'translateY(0%)', 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; }, rotateRight: function() { this.fire('rotate-right'); }, download: function() { this.fire('save'); }, print: function() { this.fire('print'); } }); })(); // 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, /** Unique id to identify this dropdown for metrics purposes. */ metricsId: 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.fire('dropdown-opened', this.metricsId); } 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 = () => { this.animation_ = null; if (!this.dropdownOpen) this.$.dropdown.style.display = 'none'; }; }, 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. 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. (function() { var FIT_TO_PAGE_BUTTON_STATE = 0; var FIT_TO_WIDTH_BUTTON_STATE = 1; Polymer({ is: 'viewer-zoom-toolbar', properties: { strings: {type: Object, observer: 'updateTooltips_'}, visible_: {type: Boolean, value: true} }, isVisible: function() { return this.visible_; }, /** * @private * Change button tooltips to match any changes to localized strings. */ updateTooltips_: 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]; }, /** * Handle clicks of the fit-button. */ fitToggle: function() { this.fireFitToChangedEvent_( this.$['fit-button'].activeIndex == FIT_TO_WIDTH_BUTTON_STATE ? FittingType.FIT_TO_WIDTH : FittingType.FIT_TO_PAGE, true); }, /** * Handle the keyboard shortcut equivalent of fit-button clicks. */ fitToggleFromHotKey: function() { this.fitToggle(); // Toggle the button state since there was no mouse click. var button = this.$['fit-button']; button.activeIndex = (button.activeIndex == FIT_TO_WIDTH_BUTTON_STATE ? FIT_TO_PAGE_BUTTON_STATE : FIT_TO_WIDTH_BUTTON_STATE); }, /** * Handle forcing zoom via scripting to a fitting type. * @param {FittingType} fittingType Page fitting type to force. */ forceFit: function(fittingType) { this.fireFitToChangedEvent_(fittingType, false); // Set the button state since there was no mouse click. var nextButtonState = (fittingType == FittingType.FIT_TO_WIDTH ? FIT_TO_PAGE_BUTTON_STATE : FIT_TO_WIDTH_BUTTON_STATE); this.$['fit-button'].activeIndex = nextButtonState; }, /** * @private * Fire a 'fit-to-changed' {CustomEvent} with the given FittingType as detail. * @param {FittingType} fittingType to include as payload. * @param {boolean} userInitiated whether the event was initiated by a user * action. */ fireFitToChangedEvent_: function(fittingType, userInitiated) { this.fire( 'fit-to-changed', {fittingType: fittingType, userInitiated: userInitiated}); }, /** * Handle clicks of the zoom-in-button. */ zoomIn: function() { this.fire('zoom-in'); }, /** * Handle clicks of the zoom-out-button. */ 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(); } }, }); })(); // 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 Various string utility functions */ 'use strict'; /** * Converts a string to an array of bytes. * @param {string} s The string to convert. * @param {(Array|Uint8Array)=} bytes The Array-like object into which to store * the bytes. A new Array will be created if not provided. * @return {(Array|Uint8Array)} An array of bytes representing the string. */ function UTIL_StringToBytes(s, bytes) { bytes = bytes || new Array(s.length); for (var i = 0; i < s.length; ++i) bytes[i] = s.charCodeAt(i); return bytes; } /** * Converts a byte array to a string. * @param {(Uint8Array|Array)} 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; var _rotr = function(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. function closeLowLevelDevice(dev) { 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')); }); } 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; var reallyCloseDevice = closeLowLevelDevice.bind(null, dev); if (this.destroyHook_) { var p = this.destroyHook_(); if (!p) { reallyCloseDevice(); return; } // When this method returns, a device reference may still be held, until the // promise completes. p.then(reallyCloseDevice); } else { reallyCloseDevice(); } }; /** * Sets a callback that will get called when this device instance is destroyed. * @param {function() : ?Promise} cb Called back when closed. Callback may * yield a promise that resolves when the close hook completes. */ HidGnubbyDevice.prototype.setDestroyHook = function(cb) { this.destroyHook_ = cb; }; /** * 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; var transferComplete = function() { 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 * @param {GnubbyEnumerationTypes=} opt_type Which type of enumeration to do. */ HidGnubbyDevice.enumerate = function(cb, opt_type) { /** * 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(filter, 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.enumeratedBy = filter; // 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].enumeratedBy = filter; break; } } if (!found) { allDevs.push(dev); } } if (++numEnumerated == ENUMERATE_PASSES) { cb(allDevs); } } // Pass 1: usagePage-based enumeration, for FIDO U2F devices. If non-FIDO // devices are asked for, "implement" this pass by providing it the empty // list. (enumerated requires that it's called once per pass.) var f1d0Filter = {usagePage: 0xf1d0}; if (opt_type == GnubbyEnumerationTypes.VID_PID) { enumerated(f1d0Filter, []); } else { chrome.hid.getDevices( {filters: [f1d0Filter]}, enumerated.bind(null, f1d0Filter)); } // Pass 2: vid/pid-based enumeration, for legacy devices. If FIDO devices // are asked for, "implement" this pass by providing it the empty list. if (opt_type == GnubbyEnumerationTypes.FIDO_U2F) { enumerated(false, []); } else { for (var i = 0; i < HidGnubbyDevice.HID_VID_PIDS.length; i++) { var vidPid = HidGnubbyDevice.HID_VID_PIDS[i]; chrome.hid.getDevices({filters: [vidPid]}, enumerated.bind(null, vidPid)); } } }; /** * @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, enumeratedBy: hidDev.enumeratedBy, 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() { function closeLowLevelDevice(dev) { 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')); }); }); } 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; var reallyCloseDevice = closeLowLevelDevice.bind(null, dev); if (this.destroyHook_) { var p = this.destroyHook_(); if (!p) { reallyCloseDevice(); return; } p.then(reallyCloseDevice); } else { reallyCloseDevice(); } }; /** * Sets a callback that will get called when this device instance is destroyed. * @param {function() : ?Promise} cb Called back when closed. Callback may * yield a promise that resolves when the close hook completes. */ UsbGnubbyDevice.prototype.setDestroyHook = function(cb) { this.destroyHook_ = cb; }; /** * 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(UTIL_fmt(JSON.stringify(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; var OutTransferComplete = function(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 * @param {GnubbyEnumerationTypes=} opt_type Which type of enumeration to do. */ UsbGnubbyDevice.enumerate = function(cb, opt_type) { // UsbGnubbyDevices are all non-FIDO devices, so return an empty list if // FIDO is what's wanted. if (opt_type == GnubbyEnumerationTypes.FIDO_U2F) { cb([]); return; } var numEnumerated = 0; var allDevs = []; function enumerated(vidPid, devs) { if (devs) { for (var i = 0; i < devs.length; i++) { devs[i].enumeratedBy = vidPid; } allDevs = allDevs.concat(devs); } if (++numEnumerated == UsbGnubbyDevice.WINUSB_VID_PIDS.length) { cb(allDevs); } } for (var i = 0; i < UsbGnubbyDevice.WINUSB_VID_PIDS.length; i++) { var vidPid = UsbGnubbyDevice.WINUSB_VID_PIDS[i]; chrome.usb.getDevices(vidPid, enumerated.bind(null, vidPid)); } }; /** * @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; } // Restore the enumeratedBy value, if we had it. if (enumeratedBy) { dev.enumeratedBy = enumeratedBy; } var gnubby = new UsbGnubbyDevice( gnubbies, nonNullHandle, which, inEndpoint, outEndpoint); cb(-GnubbyDevice.OK, gnubby); }); }); } var enumeratedBy = dev.enumeratedBy; if (UsbGnubbyDevice.runningOnCrOS === undefined) { UsbGnubbyDevice.runningOnCrOS = (window.navigator.appVersion.indexOf('; CrOS ') != -1); } // dev contains an enumeratedBy value, which we need to strip prior to // calling Chrome APIs with it. delete dev.enumeratedBy; 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, enumeratedBy: dev.enumeratedBy, 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 {{ * vendorId: (number|undefined), * productId: (number|undefined), * usagePage: (number|undefined) * }} */ var GnubbyEnumerationFilter; /** * @typedef {{ * namespace: string, * enumeratedBy: (GnubbyEnumerationFilter|undefined), * device: number * }} */ var GnubbyDeviceId; /** * Ways in which gnubby devices are enumerated. * @const * @enum {number} */ var GnubbyEnumerationTypes = {ANY: 0, VID_PID: 1, FIDO_U2F: 2}; /** * @typedef {{ * isSharedAccess: boolean, * enumerate: function(function(Array), GnubbyEnumerationTypes=), * deviceToDeviceId: function(*): GnubbyDeviceId, * open: function(Gnubbies, number, *, function(number, GnubbyDevice=)), * cancelOpen: (undefined|function(Gnubbies, number, *)) * }} */ 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 {string} namespace * @return {function(*)} deviceToDeviceId method associated with given namespace * @private */ Gnubbies.prototype.getDeviceToDeviceId_ = function(namespace) { return this.impl_[namespace].deviceToDeviceId; }; /** * @param {function(number, Array)} cb Called back with the * result of enumerating. * @param {GnubbyEnumerationTypes=} opt_type Which type of enumeration to do. */ Gnubbies.prototype.enumerate = function(cb, opt_type) { 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(UTIL_fmt(JSON.stringify(devs))); var presentDevs = {}; var deviceIds = []; var deviceToDeviceId = self.getDeviceToDeviceId_(namespace); 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), opt_type); } } }; /** * 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=} 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.getDeviceToDeviceId_(which.namespace); 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); } } }; /** * Called to cancel add client operation * @param {GnubbyDeviceId} which Which device to cancel open. */ Gnubbies.prototype.cancelAddClient = function(which) { var dev = null; var deviceToDeviceId = this.getDeviceToDeviceId_(which.namespace); 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) { return; } if (this.pendingOpens_[which.namespace] && this.pendingOpens_[which.namespace][which.device]) { var cancelOpenImpl = this.impl_[which.namespace].cancelOpen; if (cancelOpenImpl) cancelOpenImpl(this, which.device, dev); } }; /** * 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); }; /** * Cancels open attempt for this gnubby, if available. */ Gnubby.prototype.cancelOpen = function() { if (this.which) Gnubby.gnubbies_.cancelAddClient(this.which); }; /** * 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 {GnubbyEnumerationTypes=} opt_type Which type of device to enumerate. * @param {function(number)=} opt_cb Called with result of opening the * gnubby. * @param {string=} opt_caller Identifier for the caller. */ Gnubby.prototype.open = function(which, opt_type, opt_cb, opt_caller) { var cb = opt_cb ? opt_cb : Gnubby.defaultCallback; if (this.closed) { cb(-GnubbyDevice.NODEVICE); return; } this.closingWhenIdle = false; if (opt_caller) { this.caller_ = opt_caller; } 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, opt_type); return; } self.dev = device; if (self.closeHook_) { self.dev.setDestroyHook(self.closeHook_); } cb.call(self, rc); }); } if (which) { setCid(which); self.which = which; Gnubby.gnubbies_.addClient( /** @type {GnubbyDeviceId} */ (which), self, function(rc, device) { if (!rc) { self.dev = device; if (self.closeHook_) { self.dev.setDestroyHook(self.closeHook_); } } cb.call(self, rc); }); } else { Gnubby.gnubbies_.enumerate(enumerated, opt_type); } }; /** * 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 identifier 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); }; /** * Sets a callback that will get called when this gnubby is closed. * @param {function() : ?Promise} cb Called back when closed. Callback * may yield a promise that resolves when the close hook completes. */ Gnubby.prototype.setCloseHook = function(cb) { this.closeHook_ = 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 {number|undefined} The last read error seen by this device. */ Gnubby.prototype.getLastReadError = function() { return this.lastReadError_; }; /** * @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; } self.lastReadError_ = /** @private {number|undefined} */ (a); 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 */ 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; function gotResponse(rc, data) { if (!rc) { self.version_ = data; } cb(rc, data); } var apdu = new Uint8Array([0x00, Gnubby.U2F_VERSION, 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); return; } if (rc == 0x6700) { // Wrong length. Try with non-ISO 7816-4-conforming layout defined in // earlier U2F drafts. apdu = new Uint8Array( [0x00, Gnubby.U2F_VERSION, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); self.apduReply(apdu.buffer, gotResponse); return; } // Any other response: handle as final result. gotResponse(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 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. * @param {string=} opt_caller Identifier for the caller. * @return {(function ()|undefined)} Some implementations might return function * that can be used to cancel this pending open operation. Opening device * might take long time or be resource-hungry. */ GnubbyFactory.prototype.openGnubby = function( which, forEnroll, cb, opt_appIdHash, opt_logMsgUrl, opt_caller) {}; /** * 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) {}; /** * Called immediately after enrolling the gnubby to perform necessary actions. * @param {Gnubby} gnubby The just-enrolled gnubby. * @param {string} appIdHash The base64-encoded hash of the app id for which * the gnubby was enrolled. * @param {FactoryOpenCallback} cb Called with the result of the action. * (A non-zero status indicates failure.) */ GnubbyFactory.prototype.postEnrollAction = 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 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. * @param {string=} opt_caller Identifier for the caller. * @return {undefined} no open canceller needed for this type of gnubby * @override */ UsbGnubbyFactory.prototype.openGnubby = function( which, forEnroll, cb, opt_appIdHash, opt_logMsgUrl, opt_caller) { var gnubby = new Gnubby(); gnubby.open(which, GnubbyEnumerationTypes.ANY, function(rc) { if (rc) { cb(rc, gnubby); return; } gnubby.sync(function(rc) { cb(rc, gnubby); }); }, opt_caller); }; /** * 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); }; /** * No-op post enroll action. * @param {Gnubby} gnubby The just-enrolled gnubby. * @param {string} appIdHash The base64-encoded hash of the app id for which * the gnubby was enrolled. * @param {FactoryOpenCallback} cb Called with the result of the action. * (A non-zero status indicates failure.) */ UsbGnubbyFactory.prototype.postEnrollAction = function(gnubby, appIdHash, cb) { cb(DeviceStatusCodes.OK_STATUS, gnubby); }; // 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 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 file not found status. * @const */ DeviceStatusCodes.FILE_NOT_FOUND_STATUS = 0x6a82; /** * 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 2017 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. /** * ASN.1 parser, in the manner of BoringSSL's CBS (crypto byte string) lib. * * A |ByteString| is a buffer of DER-encoded bytes. To decode the buffer, you * must know something about the expected sequence of tags, which allows you to * call getASN1() and friends with the right arguments and in the right order. * * https://commondatastorage.googleapis.com/chromium-boringssl-docs/bytestring.h.html * is the canonical API reference. */ const ByteString = class { /** * Creates a new ASN.1 parser. * @param {!Uint8Array} buffer DER-encoded ASN.1 bytes. */ constructor(buffer) { /** @private {!Uint8Array} */ this.slice_ = buffer; } /** * @return {!Uint8Array} The DER-encoded bytes remaining in the buffer. */ get data() { return this.slice_; } /** * @return {number} The number of DER-encoded bytes remaining in the buffer. */ get length() { return this.slice_.length; } /** * @return {boolean} True if the buffer is empty. */ get empty() { return this.slice_.length == 0; } /** * Pops a byte from the start of the buffer. * @return {number} A byte. * @throws {Error} if the buffer is empty. * @private */ getU8_() { if (this.empty) { throw Error('getU8_: slice empty'); } const b = this.slice_[0]; this.slice_ = this.slice_.subarray(1); return b; } /** * Pops |n| bytes from the buffer. * @param {number} n The number of bytes to pop. * @throws {Error} * @private */ skip_(n) { if (this.slice_.length < n) { throw Error('skip_: too few bytes in input'); } this.slice_ = this.slice_.subarray(n); } /** * @param {number} n The number of bytes to read from the buffer. * @return {!Uint8Array} an array of |n| bytes. * @throws {Error} */ getBytes(n) { if (this.slice_.length < n) { throw Error('getBytes: too few bytes in input'); } const prefix = this.slice_.subarray(0, n); this.slice_ = this.slice_.subarray(n); return prefix; } /** * Returns a value of the specified type. * @param {number} expectedTag The expected tag, e.g. |SEQUENCE|, of the next * value in the buffer. * @param {boolean=} opt_includeHeader If true, include header bytes in the * buffer. * @return {!ByteString} The DER-encoded value bytes. * @throws {Error} * @private */ getASN1_(expectedTag, opt_includeHeader) { if (this.empty) { throw Error('getASN1: empty slice, expected tag ' + expectedTag); } const v = this.getAnyASN1(); if (v.tag != expectedTag) { throw Error('getASN1: got tag ' + v.tag + ', want ' + expectedTag); } if (!opt_includeHeader) { v.val.skip_(v.headerLen); } return v.val; } /** * Returns a value of the specified type. * @param {number} expectedTag The expected tag, e.g. |SEQUENCE|, of the next * value in the buffer. * @return {!ByteString} The DER-encoded value bytes. * @throws {Error} */ getASN1(expectedTag) { return this.getASN1_(expectedTag, false); } /** * Returns a base128-encoded integer. * @return {number} an int32. * @private */ getBase128Int_() { var lookahead = this.slice_.length; if (lookahead > 4) { lookahead = 4; } var len = 0; for (var i = 0; i < lookahead; i++) { if (!(this.data[i] & 0x80)) { len = i + 1; break; } } if (len == 0) { throw Error('terminating byte not found'); } var n = 0; var octets = this.getBytes(len); for (var i = 0; i < len; i++) { n |= (octets[i] & 0x7f) << 7 * (len - i - 1); } return n; } /** * Returns an OBJECT IDENTIFIER. * @return {Array} */ getASN1ObjectIdentifier() { var b = this.getASN1(Tag.OBJECT); var result = []; var first = b.getBase128Int_(); result[1] = first % 40; result[0] = (first - result[1]) / 40; var n = 2; while (!b.empty) { result[n++] = b.getBase128Int_(); } return result; } /** * Returns a value of the specified type, with its header. * @param {number} expectedTag The expected tag, e.g. |SEQUENCE|, of the next * value in the buffer. * @return {!ByteString} The DER-encoded header and value bytes. * @throws {Error} */ getASN1Element(expectedTag) { return this.getASN1_(expectedTag, true); } /** * Returns an optional value of the specified type. * @param {number} expectedTag The expected tag, e.g. |SEQUENCE|, of the next * value in the buffer. * @return {ByteString} * */ getOptionalASN1(expectedTag) { if (this.slice_.length < 1 || this.slice_[0] != expectedTag) { return null; } return this.getASN1(expectedTag); } /** * Matches and returns any ASN.1 type. * @return {{tag: number, headerLen: number, val: !ByteString}} An ASN.1 * value. The returned |ByteString| includes the DER header bytes. * @throws {Error} */ getAnyASN1() { const header = new ByteString(this.slice_); const tag = header.getU8_(); const lengthByte = header.getU8_(); if ((tag & 0x1f) == 0x1f) { throw Error('getAnyASN1: long-form tag found'); } var len = 0; var headerLen = 0; if ((lengthByte & 0x80) == 0) { // Short form length. len = lengthByte + 2; headerLen = 2; } else { // The high bit indicates that this is the long form, while the next 7 // bits encode the number of subsequent octets used to encode the length // (ITU-T X.690 clause 8.1.3.5.b). const numBytes = lengthByte & 0x7f; // Bitwise operations are always on signed 32-bit two's complement // numbers. This check ensures that we stay under this limit. We could // do this in a better way, but there's no need to process very large // objects. if (numBytes == 0 || numBytes > 3) { throw Error('getAnyASN1: bad ASN.1 long-form length'); } const lengthBytes = header.getBytes(numBytes); for (var i = 0; i < numBytes; i++) { len <<= 8; len |= lengthBytes[i]; } if (len < 128 || (len >> ((numBytes - 1) * 8)) == 0) { throw Error('getAnyASN1: incorrectly encoded ASN.1 length'); } headerLen = 2 + numBytes; len += headerLen; } if (this.slice_.length < len) { throw Error('getAnyASN1: too few bytes in input'); } const prefix = this.slice_.subarray(0, len); this.slice_ = this.slice_.subarray(len); return {tag: tag, headerLen: headerLen, val: new ByteString(prefix)}; } }; /** * Tag is a container for ASN.1 tag values, like |SEQUENCE|. These values * are arguments to e.g. getASN1(). */ const Tag = class { /** @return {number} */ static get BOOLEAN() { return 1; } /** @return {number} */ static get INTEGER() { return 2; } /** @return {number} */ static get BITSTRING() { return 3; } /** @return {number} */ static get OCTETSTRING() { return 4; } /** @return {number} */ static get NULL() { return 5; } /** @return {number} */ static get OBJECT() { return 6; } /** @return {number} */ static get UTF8String() { return 12; } /** @return {number} */ static get PrintableString() { return 19; } /** @return {number} */ static get UTCTime() { return 23; } /** @return {number} */ static get GeneralizedTime() { return 24; } /** @return {number} */ static get CONSTRUCTED() { return 0x20; } /** @return {number} */ static get SEQUENCE() { return 0x30; } /** @return {number} */ static get SET() { return 0x31; } /** @return {number} */ static get CONTEXT_SPECIFIC() { return 0x80; } }; /** * ASN.1 builder, in the manner of BoringSSL's CBB (crypto byte builder). * * A |ByteBuilder| maintains a |Uint8Array| slice and appends to it on demand. * After appending all the necessary values, the |data| property returns a * slice containing the result. Utility functions are provided for appending * ASN.1 DER-formatted values. * * Several of the functions take a "continuation" parameter. This is a function * that makes calls to its argument in order to lay down the contents of a * value. Once the continuation returns, the length prefix will be serialised. * It is illegal to call methods on a parent ByteBuilder while a continuation * function is running. */ const ByteBuilder = class { constructor() { /** @private {?Uint8Array} */ this.slice_ = null; /** @private {number} */ this.len_ = 0; /** @private {?ByteBuilder} */ this.child_ = null; } /** * @return {!Uint8Array} The constructed bytes */ get data() { if (this.child_ != null) { throw Error('data access while child is pending'); } if (this.slice_ === null) { return new Uint8Array(0); } return this.slice_.subarray(0, this.len_); } /** * Reallocates the slice to at least a given size. * @param {number} minNewSize The minimum resulting size of the slice. * @private */ realloc_(minNewSize) { var newSize = 0; if (minNewSize > Number.MAX_SAFE_INTEGER - minNewSize) { // Cannot grow exponentially without overflow. newSize = minNewSize; } else { newSize = minNewSize * 2; } if (this.slice_ === null) { if (newSize < 128) { newSize = 128; } this.slice_ = new Uint8Array(newSize); return; } const newSlice = new Uint8Array(newSize); for (var i = 0; i < this.len_; i++) { newSlice[i] = this.slice_[i]; } this.slice_ = newSlice; } /** * Extends the current slice by the given number of bytes. * @param {number} n The number of extra bytes needed in the slice. * @return {number} The offset of the new bytes. * @throws {Error} * @private */ extend_(n) { if (this.child_ != null) { throw Error('write while child pending'); } if (this.len_ > Number.MAX_SAFE_INTEGER - n) { throw Error('length overflow'); } if (this.slice_ === null || this.len_ + n > this.slice_.length) { this.realloc_(this.len_ + n); } const offset = this.len_; this.len_ += n; return offset; } /** * Appends a uint8 to the slice. * @param {number} b The byte to append. * @throws {Error} * @private */ addU8_(b) { const offset = this.extend_(1); this.slice_[offset] = b; } /** * Appends a length prefixed value to the slice. * @param {number} lenLen The number of length-prefix bytes. * @param {boolean} isASN1 True iff an ASN.1 length should be prefixed. * @param {function(ByteBuilder)} k A function to construct the contents. * @throws {Error} * @private */ addLengthPrefixed_(lenLen, isASN1, k) { var offset = this.extend_(lenLen); var child = new ByteBuilder(); child.slice_ = this.slice_; child.len_ = this.len_; this.child_ = child; k(child); var length = child.len_ - lenLen - offset; if (length > 0x7fffffff) { // If a number larger than this is used with a shift operation in // Javascript, the result is incorrect. throw Error('length too large'); } if (isASN1) { // In the case of ASN.1 a single byte was reserved for // the length. The contents of the array may need to be // shifted along if the length needs more than that. if (lenLen != 1) { throw Error('internal error'); } var lenByte = 0; if (length > 0xffffff) { lenLen = 5; lenByte = 0x80 | 4; } else if (length > 0xffff) { lenLen = 4; lenByte = 0x80 | 3; } else if (length > 0xff) { lenLen = 3; lenByte = 0x80 | 2; } else if (length > 0x7f) { lenLen = 2; lenByte = 0x80 | 1; } else { lenLen = 1; lenByte = length; length = 0; } child.slice_[offset] = lenByte; const extraBytesNeeded = lenLen - 1; if (extraBytesNeeded > 0) { child.extend_(extraBytesNeeded); child.slice_.copyWithin(offset + lenLen, offset + 1, child.len_); } offset++; lenLen = extraBytesNeeded; } var l = length; for (var i = lenLen - 1; i >= 0; i--) { child.slice_[offset + i] = l; l >>= 8; } if (l != 0) { throw Error('pending child length exceeds reserved space'); } this.slice_ = child.slice_; this.len_ = child.len_; this.child_ = null; } /** * Appends an ASN.1 element to the slice. * @param {number} tag The ASN.1 tag value (must be < 31). * @param {function(ByteBuilder)} k A function to construct the contents. * @throws {Error} */ addASN1(tag, k) { if (tag > 255) { throw Error('high-tag values not supported'); } this.addU8_(tag); this.addLengthPrefixed_(1, true, k); } /** * Appends an ASN.1 INTEGER to the slice. * @param {number} n The value of the integer. Must be within the range of an * int32. * @throws {Error} */ addASN1Int(n) { if (n < (0x80000000 << 0) || n > 0x7fffffff) { // Numbers this large (or small) cannot be correctly shifted in // Javascript. throw Error('integer out of encodable range'); } var length = 1; for (var nn = n; nn >= 0x80 || nn <= -0x80; nn >>= 8) { length++; } this.addASN1(Tag.INTEGER, (b) => { for (var i = length - 1; i >= 0; i--) { b.addU8_((n >> (8 * i)) & 0xff); } }); } /** * Appends a non-negative ASN.1 INTEGER to the slice given its big-endian * encoding. This can be useful when interacting with the WebCrypto API. * @param {!Uint8Array} bytes The big-endian encoding of the integer. * @throws {Error} */ addASN1BigInt(bytes) { // Zero is representated as a single zero byte, rather than no bytes. if (bytes.length == 0) { bytes = new Uint8Array(1); } // Leading zero bytes need to be removed, unless that would make the number // negative. while (bytes.length >= 2 && bytes[0] == 0 && (bytes[1] & 0x80) == 0) { bytes = bytes.slice(1); } // If the MSB is set, the number will be considered to be negative. Thus // a zero prefix is needed in that case. if (bytes.length > 0 && (bytes[0] & 0x80) == 0x80) { if (bytes.length > Number.MAX_SAFE_INTEGER - 1) { throw Error('bigint array too long'); } var newBytes = new Uint8Array(bytes.length + 1); newBytes.set(bytes, 1); bytes = newBytes; } this.addASN1(Tag.INTEGER, (b) => b.addBytes(bytes)); } /** * Appends a base128-encoded integer to the slice. * @param {number} n The value of the integer. Must be non-negative and within * the range of an int32. * @throws {Error} * @private */ addBase128Int_(n) { if (n < 0 || n > 0x7fffffff) { // Cannot encode negative numbers and large numbers cannot be shifted in // Javascript. throw Error('integer out of encodable range'); } var length = 0; if (n == 0) { length = 1; } else { for (var i = n; i > 0; i >>= 7) { length++; } } for (var i = length - 1; i >= 0; i--) { var octet = 0x7f & (n >> (7 * i)); if (i != 0) { octet |= 0x80; } this.addU8_(octet); } } /** * Appends an OBJECT IDENTIFIER to the slice. * @param {Array} oid The OID as a list of integer elements. * @throws {Error} */ addASN1ObjectIdentifier(oid) { if (oid.length < 2 || oid[0] > 2 || (oid[0] <= 1 && oid[1] >= 40)) { throw Error('invalid OID'); } this.addASN1(Tag.OBJECT, (b) => { b.addBase128Int_(oid[0] * 40 + oid[1]); for (var i = 2; i < oid.length; i++) { b.addBase128Int_(oid[i]); } }); } /** * Appends an ASN.1 NULL to the slice. * @throws {Error} */ addASN1Null() { const offset = this.extend_(2); this.slice_[offset] = Tag.NULL; this.slice_[offset + 1] = 0; } /** * Appends an ASN.1 PrintableString to the slice. * @param {string} s The contents of the string. * @throws {Error} */ addASN1PrintableString(s) { var buf = new Uint8Array(s.length); for (var i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if ((code < 97 && code > 122) && // a-z (code < 65 && code > 90) && // A-Z ' \'()+,-/:=?'.indexOf(String.fromCharCode(code)) == -1) { throw Error( 'cannot encode \'' + String.fromCharCode(code) + '\' in' + ' PrintableString'); } buf[i] = code; } this.addASN1(Tag.PrintableString, (b) => { b.addBytes(buf); }); } /** * Appends an ASN.1 UTF8String to the slice. * @param {string} s The contents of the string. * @throws {Error} */ addASN1UTF8String(s) { this.addASN1(Tag.UTF8String, (b) => { b.addBytes((new TextEncoder()).encode(s)); }); } /** * Appends an ASN.1 BIT STRING to the slice. * @param {!Uint8Array} bytes The contents, which must be a whole number of * bytes. * @throws {Error} */ addASN1BitString(bytes) { this.addASN1(Tag.BITSTRING, (b) => { b.addU8_(0); // no superfluous bits in encoding. b.addBytes(bytes); }); } /** * Appends raw data to the slice. * @param {string} s The contents to append. All character values must * be < 256. * @throws {Error} */ addBytesFromString(s) { const buf = new Uint8Array(s.length); for (var i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code > 255) { throw Error('out-of-range character in string of bytes'); } buf[i] = code; } this.addBytes(buf); } /** * Appends raw bytes to the slice. * @param {!Array|!Uint8Array} bytes Data to append. * @throws {Error} */ addBytes(bytes) { const offset = this.extend_(bytes.length); for (var i = 0; i < bytes.length; i++) { this.slice_[offset + i] = bytes[i]; } } }; // 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'; /** * webSafeBase64ToNormal reencodes a base64-encoded string. * * @param {string} s A string encoded as web-safe base64. * @return {string} A string encoded in normal base64. */ function webSafeBase64ToNormal(s) { return s.replace(/-/g, '+').replace(/_/g, '/'); } /** * decodeWebSafeBase64ToArray decodes a base64-encoded string. * * @param {string} s A base64-encoded string. * @return {!Uint8Array} */ function decodeWebSafeBase64ToArray(s) { var bytes = atob(webSafeBase64ToNormal(s)); var buffer = new ArrayBuffer(bytes.length); var ret = new Uint8Array(buffer); for (var i = 0; i < bytes.length; i++) { ret[i] = bytes.charCodeAt(i); } return ret; } // See "FIDO U2F Authenticator Transports Extension", §3.2.1. const transportTypeOID = [1, 3, 6, 1, 4, 1, 45724, 2, 1, 1]; /** * Returns the value of the transport-type X.509 extension from the supplied * attestation certificate, or 0. * * @param {!Uint8Array} der The DER bytes of an attestation certificate. * @returns {Uint8Array} the bytes of the transport-type extension, if present, * or null. * @throws {Error} */ function transportType(der) { var topLevel = new ByteString(der); const tbsCert = topLevel.getASN1(Tag.SEQUENCE).getASN1(Tag.SEQUENCE); tbsCert.getOptionalASN1( Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 0); // version tbsCert.getASN1(Tag.INTEGER); // serialNumber tbsCert.getASN1(Tag.SEQUENCE); // signature algorithm tbsCert.getASN1(Tag.SEQUENCE); // issuer tbsCert.getASN1(Tag.SEQUENCE); // validity tbsCert.getASN1(Tag.SEQUENCE); // subject tbsCert.getASN1(Tag.SEQUENCE); // SPKI tbsCert.getOptionalASN1( // issuerUniqueID Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 1); tbsCert.getOptionalASN1( // subjectUniqueID Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 2); const outerExtensions = tbsCert.getOptionalASN1(Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 3); if (outerExtensions == null) { return null; } const extensions = outerExtensions.getASN1(Tag.SEQUENCE); if (extensions.empty) { return null; } while (!extensions.empty) { const extension = extensions.getASN1(Tag.SEQUENCE); const oid = extension.getASN1ObjectIdentifier(); if (oid.length != transportTypeOID.length) { continue; } var matches = true; for (var i = 0; i < oid.length; i++) { if (oid[i] != transportTypeOID[i]) { matches = false; break; } } if (!matches) { continue; } extension.getOptionalASN1(Tag.BOOLEAN); // 'critical' flag const contents = extension.getASN1(Tag.OCTETSTRING); if (!extension.empty) { throw Error('trailing garbage after extension'); } return contents.getASN1(Tag.BITSTRING).data; } return null; } /** * makeCertAndKey creates a new ECDSA keypair and returns the private key * and a cert containing the public key. * * @param {!Uint8Array} original The certificate being replaced, as DER bytes. * @return {Promise<{privateKey: !webCrypto.CryptoKey, certDER: !Uint8Array}>} */ async function makeCertAndKey(original) { var transport = transportType(original); if (transport !== null) { if (transport.length != 2) { throw Error('bad extension length'); } if (transport[0] < 3) { throw Error('too many bits set'); // Only 5 bits are defined. } } const keyalg = {name: 'ECDSA', namedCurve: 'P-256'}; const keypair = await crypto.subtle.generateKey(keyalg, true, ['sign', 'verify']); const publicKey = await crypto.subtle.exportKey('raw', keypair.publicKey); var serialBuffer = new ArrayBuffer(10); var serial = new Uint8Array(serialBuffer); crypto.getRandomValues(serial); const ecdsaWithSHA256 = [1, 2, 840, 10045, 4, 3, 2]; const ansiX962 = [1, 2, 840, 10045, 2, 1]; const secp256R1 = [1, 2, 840, 10045, 3, 1, 7]; const commonName = [2, 5, 4, 3]; const x509V3 = 2; const certBuilder = new ByteBuilder(); certBuilder.addASN1(Tag.SEQUENCE, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { // TBSCertificate b.addASN1(Tag.CONTEXT_SPECIFIC | Tag.CONSTRUCTED | 0, (b) => { b.addASN1Int(x509V3); // Version }); b.addASN1BigInt(serial); // Serial number b.addASN1(Tag.SEQUENCE, (b) => { // Signature algorithm b.addASN1ObjectIdentifier(ecdsaWithSHA256); }); b.addASN1(Tag.SEQUENCE, (b) => { // Issuer b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); b.addASN1PrintableString('U2F'); }); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Validity b.addASN1(Tag.UTCTime, (b) => { b.addBytesFromString('0001010000Z'); }); b.addASN1(Tag.UTCTime, (b) => { b.addBytesFromString('0001010000Z'); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Subject b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); b.addASN1PrintableString('U2F'); }); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Public key b.addASN1(Tag.SEQUENCE, (b) => { // Algorithm identifier b.addASN1ObjectIdentifier(ansiX962); b.addASN1ObjectIdentifier(secp256R1); }); b.addASN1BitString(new Uint8Array(publicKey)); }); if (transport !== null) { var t = transport; // This causes the compiler to see t cannot be null. // Extensions b.addASN1(Tag.CONTEXT_SPECIFIC | Tag.CONSTRUCTED | 3, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { // Transport-type extension. b.addASN1ObjectIdentifier(transportTypeOID); b.addASN1(Tag.OCTETSTRING, (b) => { b.addASN1(Tag.BITSTRING, (b) => { b.addBytes(t); }); }); }); }); }); } }); b.addASN1(Tag.SEQUENCE, (b) => { // Algorithm identifier b.addASN1ObjectIdentifier(ecdsaWithSHA256); }); b.addASN1(Tag.BITSTRING, (b) => { // Signature b.addBytesFromString('\x00'); // (not valid, obviously.) }); }); return {privateKey: keypair.privateKey, certDER: certBuilder.data}; } /** * Registration encodes a registration response success message. See "FIDO U2F * Raw Message Formats" (§4.3). */ const Registration = class { /** * @param {string} registrationData the registration response message, * base64-encoded. * @param {string} appId the application identifier. * @param {string=} opt_clientData the client data, base64-encoded. This * field is not really optional; it is an error if it is empty or missing. * @throws {Error} */ constructor(registrationData, appId, opt_clientData) { var data = new ByteString(decodeWebSafeBase64ToArray(registrationData)); var magic = data.getBytes(1); if (magic[0] != 5) { throw Error('bad magic number'); } /** @private {!Uint8Array} */ this.publicKey_ = data.getBytes(65); /** @private {!Uint8Array} */ this.keyHandleLen_ = data.getBytes(1); /** @private {!Uint8Array} */ this.keyHandle_ = data.getBytes(this.keyHandleLen_[0]); /** @private {!Uint8Array} */ this.certificate_ = data.getASN1Element(Tag.SEQUENCE).data; /** @private {!Uint8Array} */ this.signature_ = data.getASN1Element(Tag.SEQUENCE).data; if (!data.empty) { throw Error('extra trailing bytes'); } if (!opt_clientData) { throw Error('missing client data'); } /** @private {string} */ this.clientData_ = atob(webSafeBase64ToNormal(opt_clientData)); JSON.parse(this.clientData_); // Just checking. /** @private {string} */ this.appId_ = appId; } /** @return {!Uint8Array} the attestation certificate, DER-encoded. */ get certificate() { return this.certificate_; } /** @return {!Uint8Array} the attestation signature, DER-encoded. */ get signature() { return this.signature_; } /** * toBeSigned marshals the parts of a registration that are signed by the * attestation key, however obtained. * * @return {!Uint8Array} data to be signed. */ toBeSigned() { var tbs = new ByteBuilder(); tbs.addBytesFromString('\0'); tbs.addBytes(sha256HashOfString(this.appId_)); tbs.addBytes(sha256HashOfString(this.clientData_)); tbs.addBytes(this.keyHandle_); tbs.addBytes(this.publicKey_); return tbs.data; } /** * sign signs data from the registration (see toBeSigned()) using the supplied * private key. This is used in |RANDOMIZE| mode. * * @param {!webCrypto.CryptoKey} key ECDSA P-256 signing key in WebCrypto * format * @return {Promise} ASN.1 DER encoded ECDSA signature. */ async sign(key) { const algo = {name: 'ECDSA', hash: {name: 'SHA-256'}}; var signatureBuf = await crypto.subtle.sign(algo, key, this.toBeSigned()); var signatureRaw = new ByteString(new Uint8Array(signatureBuf)); var signatureASN1 = new ByteBuilder(); signatureASN1.addASN1(Tag.SEQUENCE, (b) => { // The P-256 signature from WebCrypto is a pair of 32-byte, big-endian // values concatenated. b.addASN1BigInt(signatureRaw.getBytes(32)); b.addASN1BigInt(signatureRaw.getBytes(32)); }); return signatureASN1.data; } /** * withReplacement marshals the registration (to base64) with the certificate * and signature replaced. * * @param {!Uint8Array} certificate new certificate, as DER. * @param {!Uint8Array} signature new signature, as DER. * @return {string} The supplied registration data with certificate and * signature replaced, base64. */ withReplacement(certificate, signature) { var result = new ByteBuilder(); result.addBytesFromString('\x05'); result.addBytes(this.publicKey_); result.addBytes(this.keyHandleLen_); result.addBytes(this.keyHandle_); result.addBytes(certificate); result.addBytes(signature); return B64_encode(result.data); } }; /** * ConveyancePreference describes how to alter (if at all) the attestation * certificate in a registration response. * @enum */ var ConveyancePreference = { /** * NONE means that the token's attestation certificate should be replaced with * a randomly generated one, and that response should be re-signed using a * corresponding key. */ NONE: 1, /** * DIRECT means that the token's attestation cert should be returned unchanged * to the relying party. */ DIRECT: 0, }; /** * conveyancePreference returns the attestation certificate replacement mode. * * @param {EnrollChallenge} enrollChallenge * @return {ConveyancePreference} */ function conveyancePreference(enrollChallenge) { if (enrollChallenge.hasOwnProperty('attestation') && enrollChallenge['attestation'] == 'none') { return ConveyancePreference.NONE; } return ConveyancePreference.DIRECT; } /** * 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); } /** * @param {string} u2fVersion * @param {string} registrationData Registration data, base64 * @param {string=} opt_clientData Base64. */ function sendSuccessResponse(u2fVersion, registrationData, opt_clientData) { var enrollChallenges = request['registerRequests']; var enrollChallengeOrNull = findEnrollChallengeOfVersion(enrollChallenges, u2fVersion); if (!enrollChallengeOrNull) { sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR}); return; } var enrollChallenge = enrollChallengeOrNull; // Avoids compiler warning. var appId = request['appId']; if (enrollChallenge.hasOwnProperty('appId')) { appId = enrollChallenge['appId']; } var promise = Promise.resolve(registrationData); switch (conveyancePreference(enrollChallenge)) { case ConveyancePreference.NONE: { console.log('randomizing attestation certificate'); promise = new Promise(async function(resolve, reject) { const reg = new Registration(registrationData, appId, opt_clientData); const keypair = await makeCertAndKey(reg.certificate); const signature = await reg.sign(keypair.privateKey); resolve(reg.withReplacement(keypair.certDER, signature)); }); break; } } promise.then( (registrationData) => { var responseData = makeEnrollResponseData( enrollChallenge, u2fVersion, registrationData, opt_clientData); var response = makeU2fSuccessResponse(request, responseData); sendResponseOnce(sentResponse, closeable, response, sendResponse); }, (err) => { console.warn('attestation certificate replacement failed: ' + err); sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR}); }); } 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); // TODO: Fix unused; intended to pass wrapped callbacks to Enroller? 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 {RequestHandler} */ 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. // NOTE: if you are looking at this in a debugger, this line will // always be false since the origin of the debugger is different // than origin of requesting page 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=} 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)); // Log non-expected reply codes if we have url to send them. if (reportedError.errorCode == ErrorCodes.OTHER_ERROR) { var logMsg = 'log=u2fenroll&rc=' + reply.code.toString(16); if (this.logMsgUrl_) logMessage(logMsg, this.logMsgUrl_); } 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 appIdHashBase64 = challenge['appIdHash']; if (DEVICE_FACTORY_REGISTRY.getIndividualAttestation() .requestIndividualAttestation(appIdHashBase64)) { this.tryEnrollComplete_(gnubby, version, true); return; } if (!chrome.cryptotokenPrivate) { this.tryEnrollComplete_(gnubby, version, false); return; } chrome.cryptotokenPrivate.isAppIdHashInEnterpriseContext( decodeWebSafeBase64ToArray(appIdHashBase64), this.tryEnrollComplete_.bind(this, gnubby, version)); }; /** * Attempts enrolling a particular gnubby with a challenge of the appropriate * version. * @param {Gnubby} gnubby Gnubby instance * @param {string} version Protocol version * @param {boolean} individualAttest whether to send the individual-attestation * signal to the token. * @private */ UsbEnrollHandler.prototype.tryEnrollComplete_ = function( gnubby, version, individualAttest) { var challenge = this.getChallengeOfVersion_(version); var challengeValue = B64_decode(challenge['challengeHash']); gnubby.enroll( challengeValue, B64_decode(challenge['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 appIdHash = this.request_.enrollChallenges[0].appIdHash; DEVICE_FACTORY_REGISTRY.getGnubbyFactory().postEnrollAction( gnubby, appIdHash, (rc) => { if (rc == DeviceStatusCodes.OK_STATUS) { var info = B64_encode(new Uint8Array(infoArray || [])); this.notifySuccess_(version, info); } else { this.notifyError_(rc); } }); 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; /** @private {boolean} */ this.begun_ = false; /** @private {function(QueuedRequestToken)} */ this.beginCb_ = beginCb; /** @type {RequestToken} */ this.prev = null; /** @type {RequestToken} */ this.next = null; /** @private {boolean} */ this.completed_ = false; } /** Begins work on this queued request. */ RequestToken.prototype.begin = function() { this.begun_ = true; this.beginCb_(this); }; /** @return {boolean} Whether this token has already begun. */ RequestToken.prototype.begun = function() { return this.begun_; }; /** 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_; }; /** @return {number} This token's id. */ RequestToken.prototype.id = function() { return this.id_; }; /** * @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 * @return {RequestToken?} The next token in the queue to run, if any. * @private */ RequestQueue.prototype.removeToken_ = function(token) { var nextTokenToRun = null; // If this token has been begun, find the next token to run. if (token.begun()) { // Find the first token in the queue which has not yet been begun, and which // is not the token being removed. for (var nextToken = this.head_; nextToken; nextToken = nextToken.next) { if (nextToken !== token && !nextToken.begun()) { nextTokenToRun = nextToken; break; } } } // Remove this token from the queue if (token.next) { token.next.prev = token.prev; } if (token.prev) { token.prev.next = token.next; } // Update head and tail of queue. 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; } } // Isolate this token to prevent it from manipulating the queue, e.g. if // complete() is called a second time with it. token.prev = token.next = null; return nextTokenToRun; }; /** * Completes this token's request, and begins the next queued request, if one * exists. * @param {RequestToken} token Queue token */ RequestQueue.prototype.complete = function(token) { var next = this.removeToken_(token); if (next) { console.log( UTIL_fmt('token ' + token.id() + ' completed, starting ' + next.id())); next.begin(); } else if (this.empty()) { console.log(UTIL_fmt('token ' + token.id() + ' completed, queue empty')); } else { console.log(UTIL_fmt( 'token ' + token.id() + ' completed (earlier token still running)')); } }; /** @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.begin(); } }, 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; /** * Initialize request queue. */ 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=} 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. * @param {string=} 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 {RequestHandler} */ 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)); // Log non-expected reply codes if we have an url to send them if ((reportedError.errorCode == ErrorCodes.OTHER_ERROR) && this.logMsgUrl_) { logMessage('log=u2fsign&rc=' + reply.code.toString(16), this.logMsgUrl_); } 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 {{ * challengeHash: Array, * appIdHash: Array, * keyHandle: Array, * version: (string|undefined) * }} */ var DecodedSignHelperChallenge; /** * @typedef {{ * code: number, * gnubby: (Gnubby|undefined), * challenge: (DecodedSignHelperChallenge|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, number>} */ this.cachedError_ = []; /** @private {(function()|undefined)} */ this.openCanceller_; } /** @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.state_ == SingleGnubbySigner.State.OPENING) { if (this.openCanceller_) this.openCanceller_(); } 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; this.openCanceller_ = DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( this.gnubbyId_, this.forEnroll_, this.openCallback_.bind(this), appIdHash, this.logMsgUrl_, 'singlesigner.js:SingleGnubbySigner.prototype.open_'); } }; /** * 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_) { this.openCanceller_ = DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( self.gnubbyId_, self.forEnroll_, self.openCallback_.bind(self), self.logMsgUrl_, 'singlesigner.js:SingleGnubbySigner.prototype.openCallback_'); } }, 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_ && SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle(code)) { // When a device yields an idempotent bad key handle error 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 {DecodedSignHelperChallenge=} 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 challenge = challenges[i]; var decodedChallenge = { challengeHash: B64_decode(challenge['challengeHash']), appIdHash: B64_decode(challenge['appIdHash']), 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 (SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle( signResult.code)) { 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!')); } } /** * @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(); } var UNUSED_CID_PUBKEY_VALUE = 'unused'; /** * 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 UNUSED_CID_PUBKEY_VALUE; } 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 UNUSED_CID_PUBKEY_VALUE; } // 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); 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; /** BLE UID read/set */ GnubbyDevice.CMD_BLE_UID = 0xb5; /** 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() {}; /** * Sets a callback that will get called when this device instance is destroyed. * @param {function() : ?Promise} cb Called back when closed. Callback may * yield a promise that resolves when the close hook completes. */ GnubbyDevice.prototype.setDestroyHook = function(cb) {}; /** * 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.warn(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;













Wmo7 _Vd z>Mq tIaНhI<ް>Jw$vɉC>HJ& !rLd:*i)8+B+)H J]fՌ:rkOΔ6C<$G Iz$j晌<ˑ+S&=FJRH 0)i%۷UuhAnq|O\BFǢ,rnli$'u5GT9O%ƺ6|ceC0c\9Bf$/'f@ pU(( EoilB*Xpwg+m8OZ%Ǧة;oZZ?l%-V@[s@f}/Cz:xO*7bهTj|עYhSa'̼웗- N~/ UbR8fǢ$!FPwjGvᔌI㌃lBn"/@z`*U%wy \_^_0T[{ݐĂZ l4DظCG54CKj"tTsP:گН 9^VSk>q ՛-{>gm>Jy05-'tȯ{]}B*Oҝt9:UTkoVzȀ3\!W)G8xdS5N{4+Āu-MUE-`K6~s{x!fyýDϘE|'A蚃s#M,rWv#_NtfCq #:)߄m˳dzf5)}}䋠=˩u?p"'dܼ:=:1YU >f&xQ}x}Hvpo&{#i¢XڟL"fF?kWõ$)҇U|oeO/?y<|'n$2[bYR:?_ZIrx8:a\ Ǽx;Lg>7/Oq^ Ys6_RL{N>nj}"!1h&~ @RLl]o5۽Wk߾orn7pۚTۦKAqk9%uAVJYqו|J {`WWM]rlf LlEuo^~wuËR_ւYIO+fj)t6e+Jz's ,YOLl{\OV Q76;FS,!G~rfh nQ iAZdIk %;ʲU Y +[R'jV|Rkւ!spsq-.zDۭ* ,(8.i-=1eи#w]i6mc-n6v90j'?>rVUd; 辅%+׼r!k\FӴJ@dp  ~=X=~o?;) 'ŊF: ۷[ b1״i0ew?L |qdke V(aJ UE;yC2_zƲ |m9\S ɛ[l]Z}mﰫڮћ6]A)HUqCUf35b[J<)#>~rcB-$2Vs|v./Z*eEZȖ5._91 +ƅ˸,IGXӍ) xE5jSו圍I>8vĿ,1v9O5ҼD̽ɔye^US6gZ\-!jC)!=l'չG)_sgcBwBDYpôyf)8,CZ5NK/bIJ>xRHDwOs3)\Ǽ^3 aC bIAYwt!\UH8Cªo^?yCt*=s%5(Ό(Ę7m z%m/АP1TҎ#ld#"xz 98 -Wf ª~M2BspGG@'L$ž`>#ML#!~CW2 /e2HɜZ[o$ &Jˣs>ZIAL䝬Cu\ ozAxM^EW;i+FnNšrG0%T c~``/Hdd}Bʆ3q~²a+W{bP4vvOq" y1u]I)Bxy(#`{7` iѽDZH:XSԀƭF*# k: (/(NEHcA^Ym;<nPnBJ>zrYJ*"BDYܿE.pDCRlkhvk?3;GΒ/. f1[ YW"A0]gm?%ݨmAKZmyb`4v/g&k Ur=f*`.{p" 6NNhCq=ԕN%칙×cPVĩ(N_V&2d4jkQW dՃryh3T\|ȳ)dql]͇c Տ|4=L:4K~gYr"φ'|C!vh'DmL pi6-*FU%'5Ӭ!8WR[!~g޿9t?ʌ$kn1hnB"Pr*$ĥ쮩ބ۴^,7\̰(#|=a {P9LAB@*0M pۀhfCHώ$  ?!&b"h(ZI'|r}:0(͈Re?hM(^YP-PZ2*GL 5o-1_.αx[mҟaԸFXnu柤#S ;ŝ2>gbʣ$yrO s2`zg=^N5"!ʝ{hM-|K%5eTR&T hY$’o;ۺbJq,O{NAJ>4y1< zoX2F._OɞV(U*əٶ)܋L&AC2Yjt${ UVR ?|߼k|z?P|eqXo7|NSEmXoz6~}~(oXzE՗⛯/պ(7}Y\{abUy[(wޟwֽ?<V~<οvVWl7/}6 @U˛UٶR@?\Q>VyAnUnh7fAT°9K/Njvo_ꅧ*} /z/ʣ@?>Vk!4\wW_F0֋꥗d=З_H!$߾X7 z # S}[rS@󕥝b!䋯7_/G[!·i![T[b>DĉO6S?xNWGcU(|lM2W?l|~A~O*_l]}L w'~u`qrEJ9y1:J)'q٫/<"Y#]OyL]ҾMԮt t x+ˍ`Xret>73,El`n x_VD(9eZ##9A!,,Bk0b{H+]@Js ?I1'k p,y&Z(bSCqY>N=Ө6U۲q@ 5855GYS(\q 4fDUqj^t8ʵMV֎d 7uᑘ6N|8-l,ʑli>H,t$*t\qr$r$x-'., d|t:eȌ Jt$cڛAӁq!5ꢳfJ%h؞v$iځT ):3EE 7S3H/N+7O0 2 u;3vQOsɨeF7 ,V"7ӹduy0K RD%'0Y.Od^&(Kb<.Ti cYn-M2҄MbX\CrlE`$ܙA:ɼ^ad/c5dԣLg +2[:3X&șAYa vMKwvp(Y,'TD8QD/9G/{1 )בi-솓`E>5;xH-ȝD,YFdDUSJKU&CюeUC"d`\9 i5X1Zܝ.]4s6= ,".߼-QA62 5Ea59O™F+7!3 ;sSLdHY5d*Cf׎XMdhs& h>9jTdC!e[ao\eV-U;Sӹfoj(usLytxt.;:VCA+sM2vHGv{n% |5&8kRSjET6}1awwb֡-Xk\V+<׮t+;+cLҥf6jĮپ wPHt @ziҭ$At[ 0rh8յIE,HvmwwrA6ތ$[#?Gxl#9V`s[ܕI.smraTK, =oٖP>|WzšwGlʗ^Ը2<~GJ#ISY\.f-Zg a`v!,B=_Z[uv f]9i})7QzJ۷AA8@B3>n@ycZw̶v2k!i[ZSwmQ^M6=*m}7]Ys䫳7C(}YCOfjbrݏ1y/n{ 9 =!(U 3u=<1Ge?V9XoY?ަ+np[v"'u3L~<$ Nfl*@̈́7E=k-VP^oqz"ࠟ0CsM^G9h$9r#(yˊWCP/"T*z4yݝ%/]y믟I$iIoT TUBOϟoi ?8G1S=uɤG\"O0î<-C?Y_#ҊR~y$>D=L\/>ȼ>ѼT7d7>a6<|`F3@@:Z$>lO\I@]]y8qx+瑂b#g)S38=gcE}},m~r`,ƍp)=m1F./nڌ6~}{egv[!}%˫H=:X2}$;ur4i~?:1=uv`ZFg RkZ1q|W?Ǜ/g45P1ϲݡ%:lV4]Iid%k>r^xf|}]uA/K-@^2c#PPjEvi;{ ӲY&eǦ@#4ؼH *f! kgiGfP{Sƫm_f)s.6 qzYq=< Oj_~?xѸHU{!ᇯBMj{Q'U˻e\8h,`HC^LAd־I0߼1w~ of9S'6_RyU_Nz74,#0ʽclhfvc0G3>饙4gIZ٭7?W=fA$p5-vZ0$L 0\XES|#1(,F!KU_f"'U#Wzl|{u`+p_&VaVyaO ?+D1'%Z~ ޭHt _cNT~ I-?_tPgV ( Hߦ|Y" Ԇ!i=:| hTǏB+5|VH\=T)S3Õ'A >+L"m@$a}G]q4ᯂ?:ϊz+_6\~~:0v:z (hw%)Ƿ&`/ <믯+Ze.F}(UJz7_C ŷȻ _ԻSjf$MX~F4 te_ ' ػ3'МԟO[C%S).ɟP?ZON$PP,^ٯ:+|E~)!3E 1-%>B $cW;5p 3/ "DRWqu;wU6oh)JLpd}y?>亖z}V Nb#;v4 H>~2S4RLNgat$zuQH*,x_{EϊfqF ?ȟGѧr?KY`|J=Dme2~:K%VRJaG$8 O3#ԾgaF_)2T0= QtJ( C?Л(BHQc}xpҸ*I3{y4 !ɓdyaH]Z,CVO~rpǫ^%d]?=vkM='Tx`aTT\?_G*4FshEݜSaYLDg xRT{ x AQ"HdůE? )$ }W.̐s d$2z%De;,XԦO0owCA)%JinkG0#:SN+2ط. |I@KOLoGB$ǎhodHj9!ItI-I Mf`zHDН\hwaEs3>R7!Ԑd?KIn0#?%Ҏc)Ğ=?7T3=}t v'IZ!lU4 7E$6@̈́A2=̋]&S2eA#IiEmQG,Tr uzCj!%Fom,M"r>)nƿv#!3)?>)j %+34RhL틊VvFI\G iCTNcG9,čhb PȜԼY (xAT_fa 0 !JT\^ V{^!s\7Zz3Y:܀ͫa Ë́X4"gJraExhMXV-׵IKfHؙap7CcTW)_QBKT,dbYk%q-;v,Po351L6aŋXxe}ó/ +^zraJ4%T>MԀpU/l8ށBc&3C9N x'b~2l9gXf>\u 1}jZ R )00)3'8?L[Ty$֖XJ2y{ScqBA⴯MS^Bb[q^i7}D*@0ݴYLP2l$/B&VOg~2 dk:` rRy"#VS(? [RBP>JZ*YQBlOZGXY彐z}?' ѝo0G!/ܿzWH(lFNc.v}}IEXO6-Fl^"t}CTA}}4W,Kӕ J\4r8bӸC;_]өdǞ}־o) ꣊c +G{f^otu/nC2Ot4MRfWzu3o߿l_~\^|q lG.TFsBم|T9u4M4UywZ]gL`sb^jN Tks2]X?7r><2\GOLPo߯NLj??5Ѣ,WLDb?Xt%'{fulO5։󼴌EۛgOkdc4}w8xQ1 K=Cѯ 񤗴.U9G?8048z?T)]š_0b ìmoZb<;j?6v%P,gݮ_]V'L> SwXǃY2X;oSqL>W`;W_a_}_wݷwe^~8䞴QYW5~l0y9_,h+ߙ}ݭ?`zEdG]®G܉n_~t \M#j̆;FY~쪣D˓}}bxr6?Y?gTp؝vżh2kpTʙY`l3lk 96GΡڝ#nsI#qH;isw͑s`"Qy:U f9( (DҨj98f,ܕS(3LicDp^/%Kztt.i\dӥYYG5\Rt^ޕfe~<]>8j S_OsY-WG `KM)nHZvX 2#.w.QP^D3]=c6]rNvQB`X{ [#-֚Dw^9tdžE4qIM-ɴs1]K~:lZÄ`TUVZ jbHٶG#~d Hn _Kq :=qv$.9jezy&L!l)TpNnjvȟemiJ}&xhdž0޲9'Bډ|P,;ZOHЉvf]Uu >%`FxѳvuS9.*a;Y )]OEhsAS Ƴm h=%VCInۂS[R([I?Rtm~&Mai X|\'CSLt8Qzk9J7e^OQ2xEnȼtn.,ts3({I{[? e,Qh@)uNDZN'2H7}=৏N.zs1/ :y؈ cAtҺ-.eTMͬ+hv_) X `7D?jFuBQ<5^xV'&a,/2V!wΖ&} P+!6[L[umv>IAKWm(z b:GwdjfAex}W';M?Ǟۜ\eO c|z8w`Ĭ!g.6U%#|7C^uDQtY5q/ &;5f/{$>I!Uvv_=ʦl!.R;]#r,<,v%KR*/wC7/^Ǝkd2JE5K*;WɬQEiq*,/|![7up-:r[W m+UqzgmyWUvU\zq f*ӆ;&o\9ε"L7G|]ú}4GGS*<L]yjJ\g]I<_}U=ygVG;8=[Ul1<9LASw;=٨nsܙn>6L,Ng䢡p.B(ԞG\lUmg^ }eixZnKŮ>Nbam0ӿO(p]vuÕ:&ӿO(p/6qR$~exs_T c}ɻ^/_˃Yv]u[Q8?b7U9Nim^}U1.In>Ĩ=8A$:N哇g;6<3ȏܰGJjQ" I8ADž rai߃VDbRi4=z*Sf_A  "F;tH*jq*uC->353thBp@wT@s1Zl^,YغY$\]yx16_c45ԭL(3M['k/rv r0 vKw%R>!K?L \gm1 ;;醾%q#4ESQ#3^Pj$ej]E8veGIᢰڥw]N~'٨"ZZDh G"ÀuNQvy@G_r_庫Չ LWl-λt)Qn/U庈}uߕ!w>lMd0AbK#'|^_5j c|iO9lN85,Mg%jy|:"!!qxF~(Njƈ_~= M$EYlET{]T2=U}/$[ON?\_;o Z{M7?drʋimWILd'~}ק2v5fua{vg=YrzEYeRBd2=Lgn֩]Jn˫MoVG%ڿ7Ǯո`-z仱ѺX{i#z⦙"ʬxG*]>#`vF%rJD u-(-1AWPg#8JW1}+~h޶֭sVbmUcG*{x4:˺oڲqBoGѰuW i׼AI߱JWձ#-5P&vuCjRڟbU'ǭW-!ooj/7lUX5NۘTFIGJWW-O$[bmnB>XJ`^iuZlA»Fݜlwy%"7ӛkAVƲKW@B]hhJeg~.qtY5R\t}"KaՃ١[3Qٽ*e79B2S\p1xL[.Ycŝ43FC! ko 'wm;Zp]Nűk?!~K#)CH!W4+Bmg >*le?o?mo,/74 [t~<6Hb%.nAP]=i bVDSPBG;} W^G'`Zԗ5ҽLǓUȁș)XM.7pqy KORy}7V_;B9QOǟD \^}µj?__IVrbUd?t}EF޷@{[xTQG*9j/ͻn:g_n&2XWOfl:ZN#j7pz'p\&^?8?Ǧ]?\,ܛ;zlospb7R?]]8zGKJѩYH/ZP}Į< Z77w9B˻]\c1)yqhR3OuC? :[/u\%2%ر$P48^SE Kw{87ύaΓ#20o }?.@}{B}Em gk٦y`k3%lZF sV> IOׇZTnzyF]fjx3ldyg]]_Ȇ]7Ϋ;kӅGs O(Xvٛ u6Ww؝&>Evu2A]W| %AK3uLf]P[3~uʺ#:DOПxu,Wf<3>.7jw:g}8{s/V7S E < ]wUE;$RsZ2DUQ. k6=^6ڸ49 YOЂ.StE,5hb\*>-ۡOe_KֶʼjUn+{)vGQםq}yt'i}yo:M}yuޫÊt;m~?8jT_6 |3ci}VxYs Qz{C،ɇGA]5w鉓5X3Zɯ_P.t"ŝ0Q{ؚ;8k\7ש2D(0B0s?aWgGNLm,C4'j+l0~5i{z~39Z4أߦ8B5FS,Ӟe%4ǘF 2ċgBHu5KD~43J>,h~xOŸ YrfzE߆ޢ0={M#oEeK+W,JfA:ǨzŔq,&gIFΔlf™%Lų,gzTO Ш 'oG`3~靦c yD3KWE+~~R ~==t@ qA&/Gj#ϝ/r1W1u"' a_aH%bhdh"E12D7D Wy@hOm+{)l4Y·æ+m0)("]pc f⡆| %1:çI U+2q1\)&ژ}1%'X'H(niA^+B^A`  ah</`'X r@b\LT  i<,c:F>ʔd(-`SJ VG ` fH&PLt:bڥ4+g ( =9; mlxn 2%I`>ʈfa|+)<2) &np ]/`.d ̊=ޯhN`:6D &1,;oAHy`&43@D ֔`;2O~^8}m2jrT~6nt1< [8q^+vJva$Z>&[qFZI!h$&$nԑ.0Cu-DomF,VtkUtO 4njYEh䣦HQ3h6 jZMCzO /1/ӨOAAh 6*Bő&$(?HX `:eeyLx!x7ݟj"Le>!!,ђIQߢ#d,I\obP0}ku1p IQJD9 l ~2 Mȴ,^DIO#dL[RHU6K#AVQC  XH!aU1f!,d/8:IUl 2ɚRN{U@grVD&k\j;÷))nusn) ğiMWǤ@'1تE>J dz>[ I2}e{_rRUԐb+˃+')O ,u}bE <џ`4,gp].4)NkP bVh+Q@GeE6S`2BYoq.}JNhIy z ^Orxhv;@ Hm< DH8:?02R,g&jh冐65vH@̚DX֍Y!ucE`foCHQ iQ(؞A؆$O,-i||HJ6 S&a4pH/Nok~$fpxgxuHņ2  "d'pXA0M0Z`PP=HeḂҀ@ `Mߕr`g|P)g()zŏ2a.pa4Uabb/"Z %?g༞>6vc b/1!?fW#R9QG/~ޫ 3r^s{"59÷PEM f1u>aOxRJ-]&y ф;DCfXajoh ^̚[{]1W֣`S,PbCaOEFC+S>.R "ΨVn-{2#)r36PHf"9b"M=8f$k eM׆g"N^cEU`(33mDY pR?x|mE }ssͿ%9ί:%@1@E9fz?݀X6;%bSg3F}Ǜ<M͉V=p%*T$ʼn0%H$l[ X8a׼h@!$6HA&~fGTW-Nx83 $Hb7QcǝkhYǺ>0O 3܆T+.--Yn@&LXF0^VXX_ 4N5>Rؕ713^xtKJ!r}o5,ԵVb@ٵ7bowG Ѷ ~~N_R?rs1 B]) -4ѴrȊll`-E00&@ I (.^t\ :/wP aa1 =,D&JBP#X=D)-XۢXjBFvgޢ۴FpCcXd!^xUq4dYH0/l $  f5LAJRe /e{EA@CLR(\G #æ`h1JEްȘ2`HV**x'xW3I|xݤOxl— f93sCWSxL OFY^b#˰Ѣ h^Ğl!No`6d^v P|9K^ |k_GP]cftûyp!Ɗ8Z UeOg&j>u"8)aÂ! i2𭹆=#qK]U& %=*.-j7,`f lusb|U bW`gč}'j2NOxOސdE=7m8 nde5W԰/ޮ4<@LAH**Fk(H?{ ,EY'$(,&j7F .RZ`=zOCSQ[Ԕ"Fudoj ܪ}b]of8rp!K^Ćی}O6Ar˃'kUٮw1H72؋gL6*#2 W<ZzK/.}ig鋺;*q-VT1VRIkI%J*I *a[AŦ['Jޮblgqeq=QsnB֡6="[ĻAL* ak@|`G? 68mb:Ťfi!6lr"QsU(zl n4Ix3fE/LJ񉋊$?%cI=BP>~~S؉Fǘ^1v6F}u .?gPch6stn]H&qdb'PX Sb^#}[tơO:KSQiR[^ٷlv_ ޴liٔ ňA!!ԜS)ݺ[g>flwX'C-e qeg`]yo~@/1\f{NğcGE\`"f1!˾ײ U.X/'6S0 U29>lCU}W~YJ*0G8p]{IٽĩѬ>Yej PHxTɓZJDDfc/<[d {i`4"e@Ӿop|2P@uHdtl^`Ӌ _|܎`UOCzg׈=^l7[ȘgD6wvQtmm@@o]S:Z!a8_ u,8@Ԙۘ#?4aW8oImŠS<<ۀ7Ň`$-cg>A8fO87b[oBm`IɁFJ-4$zw=ImM =25}-{ {yطT>c?uw*9YK S{z1(rY66ynwg%RS116cCPݾ%uqc4Lj=b,nZ m|ʪ NbWƒW3H_HW0z5&ĭCFQg@xg '"~]HPl7<7ؽqKΆLB(1Ch42 Dn&WNn9Ȁ,A+8Lg4g[DZ>/sf{fbWcK/ yU$sU~z|?r ; w;!9㝐^NäGˡU]RIfA+gq84M$a!۔<$]鑖{>`@3"T$tdA8»o0yqg"A@hF"ސ@"C-"!L%2Ck` iffb/%$ `Æ{p|Lv@ibML D6#B#D0ܔ38 a&Fx \]9pËֆ}VwsDܧJN$[StSPYk|2ilFj,n=~>">]&'&SN`Y[IsxsŽfs{س`/߱G?rC~"rg>E_8ʈV>u`3+>MI9&ȿ _`v0_ ̊c_P"j8vcWGzl~}bYj¤(?otcdKq*gg$1HoȀǽɽàm$t} 힦;,M&#?%x픉rOsD vP[/dt-N#c,W|la#I>{jYͻq49>KX+6`Pd{,}E9k؇;4~87V"S=eTN4#"U*?xfN[&d+3 {A#!ŽOۉ#G])KPIG'h7"݌n,e~vD-70c^4d|Sᘏ}qI+p,8F2DATác_mcFiւbAP@@$JjBn Cêd5._ a`rh :A2 +e$* aN b3䣫"XĹW!$u$ |WnmϕY= X4hI"}]̡/4vy}! @8p]Xye?|XL߇%BoU,HAf䋛+Z=aw | q~`l*,G3rD?#ŘD35 ߓ;86gXS0k*sC5-Oiܬm CMg%(|Qt^ ]ew7CFϼh}+fhDTc"*Mt1 `4Ćv]/1l*Nl]9n}T|9)jX%7TXO >LNR i>bΔRfXMő~ ~*6rdK,\~Tp=o$@3 y>8N xPH;|!/߾E~P%8'HUߗ*Ӫyyۘ;oB&5nd.bS9-ⴈ=n~ޯ/voA__Pa2?X&ilk4{8V[sg졲SPR{솽9b8ךb RTg2ݭMw񂴞QxC+썩'v dHrj>fM C qg5vo""ع&B}R ZuLAa<+?I } NB]wa `>sW(vX$rVf68wzG,{ 6fw,cx)JVbDx3Ғt\rDL fž] Lb'F,H-$H*'^mvLnc`jÆO:J` B!!G,TOՇ+^~xLm$qk}rAKm\ISrAP1[$Dw<ߠ{;08v`ȬaҊyn4a!:8D:}O jW>V¤].yHq^W nc*[`j8O~ЗDu#ЄRyS9s]X|D{@*6#:6#^ #QּǤsVB ~ / :طdWoLnAg"0؂7$ߖz.¡q2oge$" V*|=3&aDAGJ\k95dHn:8k DrD7YP[`414\k@}Vc$كPQjF"]eI&MlyO%?ɞϥ7 +a0`C3뇣M(kMRoxD6.HPv ;۶pb DEL9-'R?d|%x@ f ܌6ZD_jF㴷SDQ$>qCg-RȄ'y 7 _eKO{&FOxlh:[7;<z|$<M ᠋ 8xG 4 OLe%ܒw%r gHN}]gwvD;ݪk Iu6n~![[6r&/5߻"`'_Fl38݃!_#", q*ĸJ"d-HB(kal+Lbli*)%\â@~Gw,OE>kpW)Aq7%EA ZEƇKCQ xdR!: v"B:9۩;9Hp[j1#jJR{sIayY`c| >'~9B%~Jc% {]ȝK^'kPɼT/ AWiTA?\Mi*8h{us\*G_pVEȓ=kcj֛Ti$m_}~:tW F =\<)[j\b t@`ۚ/ '>ˈj=0:pqY(ܜq\Uվؕ0bE|3OhUt#yU0QiNl45Mhv-%FugxhT L0n<; o=qTBÐ'Yb^ Lȱi ^ׄ E' *;{BZΪZ2S빩e/ !wX1 P<%/3fw X]4>8mOG%xb:ʞ쌄r;WyBTcCl)4INXI97I^èdg[$o$qZ8;u#ʅ/NvJtc!Bp+񎣇%gc Fw1&>=ﶻo˰G1eҷەG9:7 . g+xnP(I^ H^gI)|#+nHNS;K/-X_A2^jر/L jIm e߈=|}{[n'5bd<ͻWPR] ܭDr؞w@roC{xFCViU9ί,t܄IlvigLyO,D^(VU!]eȝ(5({5(mhebI$U9@\DV,Is׳К#l*j+V> C9C9^ $*S5 Oas{~ؿ wa =O,MCB| 3VG{b|Y?py-ow^#{OFo!&7 8Xd^lv 99+%kr,U^Rƹഖ<854MS+&!şunL|FuKa#ZV*3:90b͘0oKf؏&f(QHQHXY؞a͖̞3i%; ٴ)"sX(r_HdÃ`C9X .$1cqXo2H 7m*dߏrV!wY>v9Ϧسܸ?rk(35Gl\8|յZn''c͢<'X;LB≽7 9ޘYLOכǧ1PFȏ9ZZ5xO`&#!2ޒ67߫ o)n?HQ(BLc>x_{2<ޭf5Od2C]\mK#l$j+'T8Tr§E$|l'OBqDZ81bS(8K1.&Z0V|`K9vּ=9d | r-ôr X@5r`}.1uuN_`>bi PӪ%APөl>Z<BZ=V4cCWh}SvbN9 4Fš'!M.Y|%z#ɉ=yc#w)/g+Ih7zHd%bXB~e5[[֝]`lLyd#GrJ}b"d,g E^4u1|hu8HqZ)X1|R6~ VٙeG +ͮV!,6@b~^!c5 9 g8̀oy-*>!%^:.$l b j@}u=N@%}cO9>TӦX /8dz7Cq#k"#k9#aH.橉S"6݆D0Kq /&ץêdž~_S93$987|`L^ aEF|7YkfYf)nJx_ #4$/{0oJŷ@[WQ45ɡN6k/DޕH+4%"73tG}JĽr|nn_ǛxL{Fӹ&1<gRةlrHQIHZ=|tv؀ES6\:gK%iM#ZCL^$tۛ5^B NNC݊|?ٗ$IuYj|}KdMށWz7/ol e~U8;DB"otD?d^T+.#wbZVFvCOk?zӻA?}4\DžsyA$p޾'8rL5۠rϨ)HWGGPEvg˱?'E$DI8wXYQ6(S)o֤<KeEu[=q.S4|.I~|T^'AhS|l`H:W=M+zAEG`4=ď?W *P0ľ:y]iV,M:g!3dp0Rc#=xukO]v "WL.|vsU?pXݐ䶾= u$^o))>2S ?Tw$CguTBgDzNOpV1}ylه3v6U#V1 ݫM󁴕rxV YC9T/|_ yRksĽ,GF XG1`b^sW٩-PbKӝyIc!0@%!Ŷ%뒏c#VƸ62ƃ݅нA) 㼜 u8Է%׏8b_cǼX%>1̳ AoPj%!GLqB r#)mE5g{q~ׯWw7FñoU(&@9 p G3'' d]{,kVVVfV^*| tmc5:QSAlRnt~s0I %TtFWuDV=VFSmM)}WҦ_zlz#erR]W{ A553γ'+_&v"cCB",0B SJ1@Eq#/;h{E۶YݽH-wׇ%+-tÚуW`6wT+_yz'+AiJp8!w-݉0Q#o$qq2lVdƀ!E,>^tlOs%BS6iwWj:0=}s3p1c6PJrFgkGwM\ ww#eUO16hOx,t\Owxd)ʏ1ik*%`0f70}UfTXy~iWH7Bq֥Ab_MCA9={_ 3W M2x2J"{TQ,YDV#t5&`Zo5Gk8)x;m8& ͼ>A{7z xL#YaOV{*+kP ?3DiGa[/)0ǔM4?\@I 5"2Wz0gEPĽڱ֦9LO %ò'h[".DQ$nzABDu@t-&SOzrF:v#\?{<&<*aNH ma_Fhn]!M#*R_ fy*HLOT*Rw.ߜps~@#S+W_8o!Xҙa &eĉ^lKm %%[{wmL\U h6b1tt,7pˑD%r" fp|O9wJe(p-2eN猑qĸƚPYʼnx _v_Jii`A/:@Mmmu DaZ\t!F!ITfd g;$&a4.Bʔ$]ˌѩHe^);F1LH_?>$_}\ Y uz R[) Կ'!]DXR=tް3 #M z_ӯ6!.ŵ! 7N\c5_;]gƬ+֥3wp ZiZ~K?ҒZVSGb;W8$u6'r=`ύVf S(yqinȧ= :JڵŸr<X0O("4v>5vf1t۷2ESꨐ2~ӠRaB7g0ɩJ0M_OVuш_*iz|2eR4L+¿vR#P1=fƂ.{|6k+MbpO˼]i#J9 Dү,o7b" ṑڍ' zS3ULjátїS"Ku'rQܪssթptg"Ԣc}޹Ww^L7Ao ]7D\Cш  H|S*h|($4isArShZA 5~kin\oe5{.\npr ПI& #[Tgl}Xy"ҡѤLLA(sy7~̚?׿֯ KJ+jfLСAaAq/:vtH}yF^6rZ`4}JEҼI4^[2@C"6cE"8HK )K`?}E]&L+Rz? J x;[M -!c,L,,s>e8`WɌCSSv^ҠkOT ƈSF¬1Z"pZeT>{|j6!;Mɳ)& ៩c-XűC)h'RJ/0G ]5^zOn;]>>Cl)Fdp+)!fd2nX5}4; S[eq&ڋWK!Rz8/# `pUmC7$=oL-0(w4 |6?i`LBh%*Fr',zgNFݤ%N[%O:x>4at5>VUgQFI[یwqF?SB]-Җ|ːreGCf}A{~)$~BCbEh2kiMP~P)ye}o;~*7sZ'Lΐ*/Zj&D!gM`XsMB]5Wse>JNItFn{B|ǠJ;K7g.gHHvMRq &MOd~VkDmm6l c)WQX~H 4=mu5 :)Z$]$B8*f!:}۹B&E `sK&y*بo Os7/E.b3DD1@ R.xz\ƔN߀UcvrQRZD+i 63_My;P!L?"? zpL* )0cH8B]~:k@\0'+E1X3i<7\;>ޙS 0@ wy?SSd]#,Bs\8f\:FL+,xCq2qtljs~z /" 4ʨNg J Ht[K_O% 1Y-].{78鍚EzCL BBr*Rzp˜H|94*eNZz-奄gws5A,w&ƌm:@ᄍxT$؜r쁹D˸/ 9qFm@z@ :8O]&9iO4Ui``(!L_6/O75\By ̅.@! Eܬ?$Zf1Zn%2(o  J`/Z_cY̒2[(ߎ1w^vk>05tOִ5L%F¶&O #(7&fc5"$4 q4%{EIb\Q ڒaxr/ɰ0MInU&#lw5S7Ճ3흝[wcq[]xE0Ϙcp&R%BS(6x>5۬! 0!I[֮Mόhc<9njFByUS<&%dxJ-2A>͕IVjP.RװZb]a`k(i+$4@2D8."5`>0׻ 0hc8ٵʷ:Lfm6Bԭi <{*'fNTHv:RDeqܿ*(&;4cCyv/gE,b@rhRaG{(R"f'q/* UV. B!g _#E@ۛ !uؠhw_%"^"Ǘ>1 hrzNh^ ݬuh,f0~,v˰v+uIΪҿx+wǶE'ͺcqևiQ yyD4UܯY^85އ{$n~+QmP^n0YcIl̴@ 1hF ^Q*aToFЪ,] t{ ,B~W8j{>y6 4LP1Zyy ?J4Xq́ L'*5ߢlfX{(DbƀSOssF\Wh&W<'Nݬ6Δѫq[V z4vh_ 3[P5f i"4պ2vaơf %iʛ8`6z1,p"HO S6̫I@IfSWabzDq`Ȣ}#^:#L7-EmРIpx/eC^%-3LdU'UfdV24AHiv~6P6Ю$rDh2 O!1S5G^#f+F違aym"1oнi97cTfɛ CtU<16РqP^_j P[|F>(1նHkd HdaSmdyȯ2 J5D:dƜmyfHqb\M ,.A]}xbhSqᑹrQ֎@Irrzt߰rUFk\8UDk$]]4MYtyAPEA`aRc5̾i4)IEZJM?ӑ9} :vt:l2*_"jHo3A f*jA| ݠg@FJacj`dLWd `7j ǾN7Lcݫg^rNkAkfS箉4 C`RF~!TR3P! .GI#S]D/jů& M1G0Ś1}*XH">wF}Cv]8 K6 DːᰎF "3X~#kB$yw\޹nͳ}f 4X^"sWSjFNXݏv׍ (g+` +>Js\Ӡ[,Jt۽LS^AnZ\@ Òtf+dfy8 n#5 !gr;>L\S?Vjv'Q5,<0}иi*{l-!@"W3@,i!KH#B#.xvBEjq!2ȌY}(}FHQi1O%Sʤs\6$ujȞC(42,oB- qe@inS\9FճG-liKScBm/QhƍIzXjHA;w'Bu<G <;3m+ǚ֔LQzۣ ovu\uC/:'*v*RVPiDJh_"#ߙ&$8sVo&adwC:+ةyy)Xz3 OI0^yy G˂3vCUExFgPB*ѧ !gG\% +&xLu6N0ϣXxxT S~5r8y5Ќ'P"SIS`ԗa6:XsqqQc4шz]c‡T֌0拟jyu=g0 E*( ,4]*ϊ~ӆoʀ +ӄ)LhNDx`GX@Si2l~iSy ,bpc 6 0q^m\xHI8u~wp[ZJY7} 5Fl8! y4IKZ$r% Cjژ=K`%̨,]^bQ1~*C䘌U*8SQ&:ob/pp&p a80Y(@: ܰx &}gzd'rBN2H=>o ˴Cus4+OJ͗|Īj{;e.54~H vWSu7&+=Sץd)NF鹫yhvtH=IbB{& cX,Fn6,-#$ԱcdzD R5X2ndY/ "RT?FO##-f@טb'WϺ@08VQmwW1D :F^vلWx $a684: byf1*ITWw۔!0X(P>и "We_&*z3TN_$̅%h `HU$7"% LbfޝhI۝ǀWLp^2.{q_vM=ϊ|РñAA`7}ب}{cۨ!56MC#zT=x=л?top/IQgU{}4ʈo^<l;, suܜHg&M6'{EK[G6/;} m#uZFV׾ !f2(JHi]@™ӥ=ܒȗMrd_E)W`X/]5Ε7).GaYH9 b:Q,k)_V( ͷFFHo[O ^ 'k͗ruˆ Vok1[մ+']V%-7%۫śiٰl2'Tt9> А8K7ni */0pRVo)(?a?ݴFy ~ O!|~{O@EĚ6mO8jKcH>9\©5 wo/emcˊuZ'hsם',#aspQ< i?\Szl^V5j<@[+ -宯 x;iCn06D]n^. Q8f<7-P,Bv ޡ,܌do6K$,ݺ{UNKw `6JaaotuGƲo rطj>2J-w͑|I!ґ,5yM)W]ʘD&4}B:ivk) ΣY^C3M]wC^+~gcѾ=?3qx1{(HΡSF5'Mu7kse,=fR(uAs-']vrm3#ap45]g/ĵЗO*L.+E.֛evy6qx^DJ9n2kǏF.Q> Hٻ90y@!X(:ɳ͛N|0pafdsڣs[Æm],.BMʺvSDtzr#py޾hnzzB:lw.xUKN6gmtn-]ٴZY\c$vPEFcVD@s!R&+̊{Pc,_]*6Q69ؖNC+0nA4Nyc#Wzo&fG#ΎrDudgG5a~&w*"#Qwj[ye.4Rv#]*׳Yll{9ՊIҳ'$G1[j:= pke zï7eʮ~ɱz^|9qAϟ[aŵthqy{ˋ:z[o7FFְTJ6wwo1 RHhdqP+4`P!v +jHh.Y f̟lU{z@s}O^7NF4t!4Uf8j3jTu5m@龙oY?dfSUZ5<$ovs_)zZ=Fc:9yg9 &.knF*&Y'ңApCgm3v. 9VS)P.jR'knN z%^4Ԝ=t!kk'obS7eaLZ*29 ]z/Z~\Ck6f!ŻuVЮ;4vkzʶc[+5'+3Z8~߇hzJ5Ƀ=6(P#̈x|~4Ez`5:|ǜBXJg's~?oٕ8jWz?I֪tژ00·B> .~Ͽן*ub4FO!Ƨ_?xwymHG;#V#E,]M cZިYDDV$\纡)3r lj|7qNÎ6˧1mv1/ݱGELᅲvDʂL}zkbu<.x<߾9dؽ$e4kOs'[]eTWZ ׭k1ӘVRȏ}Z82 GZO{Py⾫7mj:ymgmT0ט`x|lݷ9lƢzJ6}HC|f\m}G+G =Uǽ|jb/6-7۳fM9`'V8Ԣ?.Cf"v^PۮGE޾~2mM֭HR`omp"c7FY=%~Z0E%aB`f66ԱIP~BĘ/N.)Ѣ&Wt*_ Qg #*f]c4'nYsVziy gs??N[@"LP'}m6EW3ٔgɥ̛s|I4/gϤV,+x7\+Ҋ'ƓI[fNz AcPB܌?nq3 }@ OEb$E_!غKzIpI;S7E:T>B8J~Cn" __0?# L] hpl[W.eY hٌr}}UCJGBI U |6KO$OD>qIA7iOkz=(x(z@WޜP{hjgڛ1Pe`^唝qT^AɦdD, 4Ȫ֔Ŝ PsJ "a?.XWXwa, #ۅ?luh<3X@6So^68b췛Y#ɲȁ@ )lY>ixyfo;Ĵs#wvvO4 0rw/O\8aMed/my]Q +D0* @u\Ɩer.HRzvA}9lܰr@ՁTgu|3|Wg!@1` ^ `T=z3j?Y2k.̉遦)pVpB*ɲxhlըRC 5 oe޿{= \hc^+:#hEr64+eT3R(Tpby.3sgqB+vT% D ׽]ˏ=Y-_y$dmMzh-N"5> _ H'R7tBT13DH`JxY)I00|ߙ|x l%p n;䅐p²d$SgF?/goR':qIPM2#߆s3|KY[yˤqˠGf)5k%CԅE=N_ <<—o IXr!}"F=d)b=(o)SH_1&GL51zi$DG%jsyGd!Ԑ.'/A/) DO!A ]R :-$4G%p.8Che?-Ch6#ɢ/bT)`9U.?_ЎN{lgn]&8\8IODzKdN)|yD,o: _g^&'DzWvCs#?tq`ihy˝MDKy.Ȑ؉h^zJ;K&!X,^ß+U D%Ҡjq?@@@!>E6G:Ph,k4ȗ?󠷔n|kXeӳ%ҊA& RʋQnO$#B/Pzl"+$%GdQʬd $9BSTx2YP?V6I.YS! kq"b:Bt'UI1@al7/ڪy>YRj];h8μxދg=$AogYZG7`Хб3LE!jPN 'AW^K)SSi }"{¥\}*䝸l)Au E$%N#.9]B;[5"x_n-9(MHcB>!02D~A-%T܀ywU\C/]QteV+[/4_& .DZ[ Uh&`ǸX FqlGGᅗ8X<1QMTsHoqqN\렊َ A: g5**tds39m<ư8i@#* #8*`DCb0!qqCv}q e%. 3eӧRugn FE!)}r6T mc SׄG+\u4?¾ՉRH5l{c6A!LdH`k/p- W=->M LrmGp!(&㤍.pKd$q: {lCj1J{hrN==q|`^ a a(R0307~$aTfڟ._e)59H"A_N/hϼ\) n2TdhJX|X9 2ݻcqƯlʦ1'v,Lyȗ=mE ]NT!hl5uf8<xyxii/3ɇW^-}3i)7܄3H LIxsoe^w'M^Ӌ?d29}36dᴊ_3M<8aGK(kn )4?6Y;YM W)WTWk[u%ъ7TQt0OR-2p}[o`eLLv:u ,;óu؀LU殗sR],(wE۷&|eh0(jfj+N1h -ơÇZg J3oUe8*PL!eb1!g#Ne#HN3lZM}̯ !0d0]T:cS?9L(tp:xx j#I; ,i?p$X2 Tj !|v‘\v 2}HK{!,]?ٳV@DbXϫòęӢ Xhj~~rOpƗ!m9A5#[+_g/w;}H&j_\߼J[`OcCNYYly|ϱKTضll|6vi E>|rh$hn#Q"=$GԄC?~'clMFXgEJY \i;ݒ`hD{F<k|}:;W,", ojM $v=Äh|,I؛MiRJU:n9(_6uRDži'~xxZV4͛\%>(Y)vZ^͋22cotFvQV*-b+c(\8.M1FCtZD |g^ 7IvcD3fϳV|Z ,Cx>?06nwwy {*A÷0mF}ߎbۇiBDS8+תj}#WITp 9Wl>Nr $Sž&0سI6'J!'׻bo0k- 6k2q>0${L~7Yf>}TT@~$d ;UkY4hS75H;ٚtXk})c9&m|FScG!.4]$yĔEt (f9y̐SӀj^ʡ֜RsB<":HV%w)rcGaT30KX VȠk|`f~4E3aYgF.ӃWxd]`H0!=&TiySoTUU'4U#zOiST G* auMcMFBg4=!n,(BYN>^-SeQh'I1daJOxL )gn7/ƽnfk$M]VL䄪rΨ̰gg͓A֩uMuɋ:އշAUovF*+\!z_pj׏ٖw̫kFH \h~OEgc3+3hfrCgLg7֛3OoQǶ 6׫W n}K jQ^Yw0x:Ty} oXmѲui̪Nʁۦ?bߢzs(:^j0kz']#0vD#- YLlG5GJ˶:ׄ@}x+XTl[) M:mzc  8LV{N}p%S&1p8eR=vzҡIB8 A''/XS LL6['mƶK*7Tw'G, ]'vqcJϟwzcT ]{K'CKm(FuʷMzU6 ]<׳V~t{3|t5O[MZu&֗ (Vcu4U%N}ìL'즑ߕE5Wxdą| U6+FW2 4^s\| ^k)+_ՙ0ڿ;taxM͐$'9Ԋkͤsrښ&Liq&p=P)}f7lv0s1mhڰ"^ ݮ\7Y:gz`>9y`+Abf;cӲ[P?[{R`z+^֧hXTjο7I}^8f9.&EIUJybk/B}Mѥ mk{_ָެֹpcM㘴r'+ƫ4v>/q{,4'n[ǧ\tP붵iIXi:lUiu:P2^rGB8\4pC !H<{z5G}?ƹq.p.5p.xZ ~- :8C(P.B`x0z+;@!=Lǁ==н ^A8tx{C@ׇpOF.̻c700y~-y/u]tXSae[O{VįiZ*zUk?D+ bJ⯋+f٘u]mOzk[`\Y0fo38s:0:BsP>!]<M| ^tf%ENK53k !M{B,3(f:y:2 >rTUCfmK wLnK&OƞS5rlʍeA+Ku>~Aq*v!=^fdz`}]l^KX,I`0e'0~y;rV5.NBa<~_^TF~}kȾpvz`Xi`p[Dz6uHڑJ]sg^8@DّhÆFmzi?jovk-ϮQHg̯;į(7iYۡٺe{v Ja;c\N7(,4;j`btR3_k$4P6X$mWLNG H-6AXm A4f1RpҘ量5cHuAq0D+_b-j*`לE@MZE*o-]1#JbT +ޞՒ{vi@hu*[-4mSEtGx6:iA@L$zG!kVsdKGᐹg[A1SBDo4^G6ȷ|?Vf^4Va:[AKSR'pv [[IF,^(q]9'M `Ξ㏷<^^c݅'śLURc6kj#ǘaAk\xz>->r9|wƧV1\?;nRlώPW7Цn 3RZ5Mߟ*tD6'ݒr-܇8=>y8>#IW72M~hWgCl|9X>~G!p8iR0@,Hr+~GTh vF!'f^a>cڜnH0_a4+ۙá Qz,YMoSm<;W;>3gf)&i!n t YM/?w~@jrWe5n ^l"X*x̸/CN[Ckګ٬7+ @I[~;bh_-nZH26 ^I &!0=dg.od6-2q^gǁ\bob= ʳtrVXMi??痺M!hP%LH#G2 <;[ҌC-8)z)/] pLwOr@ќy>@H"$__mUY-)W?8wfL;|v[n3-HƩAYz_U2 H"W"kw~U7%2Y[<@P>\2z( 9H6;·®uЮKz/T&% Pw'F ΁_δQVC@fEvk:WGo F?MHp!:&*~;ny#2y}/B{5kA?86~Zv߭WCCT)rOhJXEy7HU8E)Ɨs,1o:CcdԨun:M;ҴoM'1{z8 XvAXʦaXH;-ǤBXc,wCur_06^n:ocA0̷Wt*ɛdo=#|OUfj*FB .lϜevryWV+,[ {tvjڻfQͻ 펓b:~f U loRa#1@ƾf={3m4|YfM.k% ~]mx$U|kC(BcQߘtxpzpy:87 (ZU 1Z)D$V:jĖ1 #b?4'Dp"еaFןm/e©EijY߼iru!y򷝪DQE?ֵF1U,ɕ0t?{/xT%t[?"gѦehnۤ_NOkouN7P{$mjKɥdTeƸ'[1Q9f)rpŚ}Td}vLuaHYkBXRXxi!~&5:ژ|Oy}jZkao_iz5k}9{ϵǍ?.W۫ysyL"P% l.ExhKOά֑ ~t) ;òK/SQฆv}1y}i vR|}o eCJU|hVޛ#TaNgsP}x~[7FZl'^YYoyýFUyQ60)ΠN~<8#&B]f%m% UͶ̂%VU#8Cl|ΊV񁮆SoϴYdyTpֹ ИyU~);moΧN5 XА:] $&iUT6í} YR_+݂T G)}ð6T7m# WlEHؤd%,VW]us!(JvT8@(}8sߙ5VSS:^5uWIK彝^NznCnߞP/c^;00`moaj.BfuGEAjUq1umi>bv93] ~UkF٠6{^1TfYO2 o7Uŝyr4)f).E9'Y}6oB٥-̦$%qgA]rS(O/_|ײ'W= D8VsA>7Ğ*w҂ ~29rIz۪ϐsՖYnچX w)?Ckz7kA@G>SXaNs8w=ۗA/;^+9\%ԟG.Aw{L$#vtz :$zMR$[Dk+KmjGE{wqE!Zfi0LY{YP|3_xշo VEykmQ}r!BV; ̆ez⧎`4$圧N/6a^h԰АWtRE!ixsڦ 4R 3z|K瀉.˜!zpA~Ew̗4h\E9]ڼʮI#Ϳ,V+X|6=&iX ukoXx'2%&:|.b15Cqh3; oELNj!W;Ϛ^Lś=Ẽ,wh=wX[~][& /A?勩$7TvrTO` 'уe_5̱"ysqի>sSf3V(JFz#j9VIQ)AS&D]#+Z+lU^xc4Y 'B 44Flx?(_{/P ]?t=zX!nbPߵ7Z8[em C8@IGY<_KȈ%\K#= KK8q32*aA-FE4JςM3,ǗOT6&yTt<36B3|xxz<)-rTqEI3:((tٙDåS\ßp+C0jY\ =^_l8éY&VZXDܛE:? (#}>g;NDMlpM N|ÞoL&VWj>CbJ\KՔ\=nr_E0x-t w3 #joc#JD`Y6 w 83)o8)nj@=)2$L![5YP&04ՍGLͷ5=,fG W<- t=$79it`s4L.f 9C1E[̺Q1X'_4c=wщ pENf`Mn׸;3zϫ>H?$ kb/Qq#Et5MF,1-,|ON3Se{2^l*_*$(=R,`fm^.` U ?_;H,뼘׫vJ#'q9RY#oQ%pY]WKSq&0c!6fT~ĠF@ 15Wn;I81t$Pbw#qͰpVN%uoC+$(ܞ.ڡ5uDa76"Ț F718]569{CIrѺhT |97dkj ?]iRloA,  DuFUBGzZ/idR([EJ.!z>Ӓ_[9(+ϼYv ;!@&Cdfn7/}ڐsg3R$JTz|mU|7kg7MU۵dF@Zy!k|ZZ].Q6*|^j.ֲWz~7&(BM6_eӵ1]xBzvwxvUL:.'wyչ/~4*WQ1lR^Gɬ*gy5/gbz?W'i~~7rgdZ΋Z$ @|&MPU_MܞCbse2)jhE^AЙQ>GtC(g3z0ʦu1/)t{0Z&zv⪃ab5uKDă|BL̗mݠMm9&M*:N@([u6CR`-YRNw<+H>SS!{ӫX θXn_nVYEwl1 Q"{X[=ojzxs7'?Lg^$Qrd hw\8G^]$b.&:9axpUC*/D W{ⵍ)*ˊҨǃY6ˠh {, |2G6vֹ_*v TM[SC@Anʯ*8j/L!}̪1ifrťwos_j8ǂ)lԐoc!, 3[pwrM&ڳ=dGaDF&;Vw PFwyGg#Gӥll6!v BKJ*ArƂUHs`Ց tb^lFPg =Fh?ZGG5"F#e05fsQhd^G~h9G(FY}7u|m#!2GV{}}B(Z=s03Cvh)d"^yDYmO&_Dqy&ikA&oRN:ŕe8%:ʏ84+)ʙ*dz;X=Y<PPzBۼfΞ = $F0T'8:t:4GO;|>?/{eusZ"7N>WTB7Hr!\\6HFuMgѬ,|եcr&ҳv9)Gk nr<CgTnjuix * a#żK6\z J`rS%СW{039`ܮg'~z[0U-$Rr@1 鬒(VM8Ҭ 9Lu{b4H.N0a)FY6ʡՔ-$γKzy=iu.X?:[_6d(yA%Ͳ|1_o>SA4}M?)oї/t_I~KJC5t%_mBWpM{iۣ]"Z|70PǢd4&l}֏_:f$}~u).Y''G?aQOw$:ٹ̪%gHP-_<蝑finZ;iDML՛0᪝ r@CZtzSVN%gBQim 0$^otod?<(RmitvoVse*ep`U'60i}.̝V܌sUBw0!DW߫)Tu?5H6B oP+jyл[\4f<*\QՃ䭊Qtďo~L>~iQ1ԝ/_l(4 ֋/mN6dſU?4Z@oWR\@.VP~ D5\7hn"'4aic-&Q; `@9wj궙d{B,!C@7 (|a_YkQb.' UUku@HS Hv`uJX왂D+ iFMHcC>G?Hk\G6#pwu}<"AO Pdy'ڈPC/@Z:M b?6ʲ#Ob4t;"ҥdUllΚ\BVgKP3jTN$lM6sŎJaW =:2 *#寻E{j`>Mrt{vC\F v&y7+lR ֧VX" cE.E˭ ˭A?pkR._@֋F-`/7[/[%;}~Z9!O,p> p V@1Tx'(-b+%3hꮔJb,I6w5A3WjD+wm>1$U(Ѕ s!d5Rl1{ 8=DǛbnՊ]SF,&^Y?52aᏩ«ALh qCeA\Z?%;9 3c~RfLCWkh Ɔ}(.Ԟ̒'>ޝ=zk*uBrK }-L/k; [,{F? ?.'-{l2aI8aV5އ9R'nd{w闛|8}w}t8|{QDžk,ӋNOd^P;N޽Pk4pM;, {?5etb6#Ap,DFtEj?ySNƻoJ1,r^Uɴ,W8[BX꫃_% qI͇v#&vMOD{HKnml^CذIY5=(x [ʍ᫽eZ(ͶN&~oQ@b+vj.lB(lR |~2>k4qtVT,f ji8قmiK#&`uZ9YF4Ǽr9VpMj;l5;`':@fTm+\a&L du8Œ|CilAucyBq'Yt={NEBLNݦJ俘۬JzK+qx&Z]j6^e WA牗lRo&we %ufZjٙ0]YTr/814j!|J}R?x)Eͽ-gM89#S~&^bUg՜^qN?uF4E;I1%5={rD>t>s}.q*3,t[LӳŘhB@#\:-Nx@S'F<}{ӅhHڎȾ&ˏc`}6-nQ77V6\um=ވ5ٌAtۀ6 V{?^{OcjZu/_E¿Кb2G̱$Պ0dٞ) a"6ibv#kђ ;XRb2AL^Zs2s~;J*y ۴Q1KҀ{vfSC\~$l}F᛻Ў6lhgΥn/9n0V>J̧23`7{9E|Lmg ny{ӷv&ة y:yP>jN%m@6K܁Y65SN^kg:NBִյy` TB}m4î|T<$(ײCrѵz@ș8(֦9GǞUZϯVVFc44NTL@\ؾiHFҌ]ւXU `sLB,*HKݐףډ\x-B8y'&Ğ\*lHNVb*Azjfm_ ϻMQNJS+LJ=b6 B=)@[-dxw^o;8I(qyRZŶɓyPAg*[|^i81! [=zkmo] a!#8;^WS:s#potAZenu㖌{T8S%lc[rl'ʘ5,Jf2WZW^iks0g+6;BdM-|w%;Qb](aKh<89oPMz;$uW &%lxڼ.f8vqUi[nڑ[g}J_I^KszegeNX{zfKtʰq- #9cӺRs_kpjrѫvU('_y&@%57GVf9]mem>.`nk'E֒kA㇑kw}:ZGi."'Q>Q7@T;3{Oж%Ehaj&em5K^mäF%'$}/e᪌NfUUR!KWڹ%+Ը r5RU=g5EU[m<+rCAj۷r eֿq6Pp/# 8sXu w GF kؓCdE⑻*͕lsuO?}Qk/Ʃ[)C'[F 9e>іar#{ITq<-.Y1u\Z '7C~%.f[eٙKsW(0*AIA"5(Dy9նK$c }=Sv^&Q?嬇+eĉxH8!{7P9-azj9wŵf .Th:l i$Lᯜ0!_OzwKRx&xek8W3V09M/Ϯ P\FA4+BUqY ZH<'%<@h^f#́*te_xAFL4taS;gg^^RdCgQBcC/k8u&kNbMflRT1bh#DaThW|qNݳ[Vm&? "NZؤh;2ao1 ҆N r/  EiE|n9%8m/ Q%ojǡVLLrflEڑ?LS*V|ß1+[͔&&v⡊̣%XF[f3ACbA8m_srF{,Z͚}M$|&0Y2G$FZ:}^| JˍϟrGhHu$ʰ/[֘;rS#kJ+[l߉Evi֫d򸳒/VW^-j2*+V?7+\(g~E<)w+U.UHGpSZ{w謒@SC K?5R3<߼X9މbf1A6"Xu{e-r[dhG lmCKh֐fZL9\[^ߺet2Z#yvO,T:5q) =BeI91I2, #ܜ)16a\pVuq9;3(j3ymHzԗQ'Tbl_%vgR ?Y*6DWY/6ץ݈kц. Щ4(_U2'e6I[ͫl&կK9:A# N##@(LKB+'9)"N~<b62ujgȆ 1C5 gDt,myVYi#2oR⦡rdn Q Z_y(@soʼn h*pMs1b-*L54y;INMEe nq_x{$6J&wIWΫ7=;#? OlԱS?GW|[ܾ@̝K͟v_ϯV;Ʈ)R$O(>;;Gx{%p 6 Z6}1{JrA5Ok8 0RGb*r41S;ՅS ܔ.˽"rš ~%)KaSoHH0H}S`ffhF Q9`қ)I g'ʝjz f7š(:eϡJaxsʞ =5~@S+'53Bց2ыsm&7!;DP=UG1,/Em\uߨz/#{5n=yєHg2_콿}ΠĒdÒRVJ~a^ loTNGټCѼ0kN7G{3p(dX%ZE<>b|̑@I4ehO I禈xP\QXyGB>[ֱW=<ܳ#'o(s@PUi/nQdkS?fU~4ٳ@Lo^D@fp" qEhW>I5~&aNNH?Uo~&*ObVغdH| IQē}; J~05qEǖ 6fd>m}(3\%]ŪOtO^Rx):̏NMQ^])xPLL)|B*-$<8?74?xEOxҸïxwfاfXSmR>`B>`0GF@z1#Ռ.PA ,||̏_ljN?KGUuݽ)Z__u"؉%R\iswgWF\t3& }i?Q LсFMlI^`GzVb%/TwܞL@Cw-ȶ72|I1}ߥ?oV(;T^\MG-oX뫱;e4+ߕi?iYel?AHޯt3'a^8WOx! ]E=M\o:ަ35ŭPQ=tS2qw8@;vxQݘC~GjzJS6V󹜒6>DowD3x{難_KDgڢC<6\z͠z1AXNGZs`i5\kb,%^ֺZ+Js!w!svYw:5HVX5>EםӦ c?sgޘIjFXD7#5 ̃@;N3lz ۓ7 NJz!^͸1V6aEa|/37m^?^jAW&硲9to;R:"3?_Dܷ. qy9g(>tUq: M~޿Yڣ?dkwK90V*D:5eGTIt`_ܮ8N>_QP&ZI#AB 01OU!YL>WdAr x5r0vC;)|LP_t9G.It|͒l2ar~,)=b :f ";ȢZmW 'a"ǰɀMl1_whʌ7;X't[s\Ubދ] asYpkWp9B]+@_;5Sӹ}ȯE1%AB `9es1?C(dZ)6zXIa^շN@gD9tWcg+'#wKf+j]U@M5W3'æGT=rRF(OjljLBà-ab(fb B[MYua{}`/KC.:!Uk}q:K:n1gcʶlq 9][LXշSR@iqHB4)+p.07\S[)*ى (Q¡/>wzwQ_FD˴>cX{=S`vnCCŸ1蹏vjj}3{|á}@7BĈE2vyqa4ѝT`'㤫%{b^2IxOme>q=ͮ(+2`%pi|_ i@ޅP̋ǡ O4^s`!FDY5}} L=BKThu5%*~u42u*Ƥ7xAn7҃M%!sdz=~ګN9aϤ&OY1y zYqqh{)l0fdmz[:e9HG>ޟSa( jSA\;g ۲Xxq_6 EhbYZfGdsg*%HtQU\S-q&š􋥘QjQu,sZPCްswK'C®1) rE%kxmSC;; Ԉ=J1;ڊcRU+jb.î-ϛPϋYK/Еcq{ 1;WfE6y:O in_Y -z[X+b&^^]/sr9l7+5Xuٺ]_NB؄˜2Vl.4-mzG:=꺭E1u^s"[yG0Li3r2ca-ޱ)Oi 6tI9&h{hw葉ɛK$$kyG҇fĶ!mwGYu6x# ~6|ɲ17 PM[O0Kwě^tjJ*a Cju^IG'}i$yR!pQAh!8 ]PWBshЧ[`^_*t՟Fwn{dHu b<4hhMŽ?x~Ӯ 򉝫:Y>ĔPز>-Mt3aoJHhկ)~8@ҵʯq+)# zl%p~8tR!뷈\vb:6nڭDCI 0g,3囸]YHTU||dY4&س5Cc/nMG-5Ly4( U,IN*jdZRQk(ث.̗q̌`=rL"%ܡGB7)Tmƞ;Yh+fږ'x.(4Fݑ,{X`<لMup#<[hjH<nҼˢ:Ŀ @k8v  U{ 3LZ|k1&]E$cs//'low}|}߷l>:yKʎL2 tBvfz{`Oas lx {VyݞҦՔ^ὓU*r\=:z}N. KGKfLkc] e^6VP1lSR,x&;vWy7͞soǥ\p,Y{0"5_3!G GW{@!n,oXٹfZ֋:!ЫBY=m- $Ut)LNfMQyU]'L>ɧ2BX1^hc4ƇEN V*C>5_IK"f3F=TH Ul#d Le0wʓ!v%9 HK,aiG@PH'BpB^@,NZI-Wi%;{M^W5!GhSX@4:≽=m'oc8LCK5#)|aqQUKs 9zkhÖhǼۼ!$6³Ҹ_\6WgG'{}-eGK?P]m#|ajq'zL]Lg>,Ŵ~:ĤS{3<~y9_?^lĝ>"~>{Cv| HXXU ߓrNWNj ht6ɫYf*G,F* ʐYQBN+&rF+Sebёk'=М׈U(D$b :O:/tT﨨_grl5sfͦ:>aXjʝj71SGRH(q(V yYxqC[?V@y; Ëb1NI4/Tk6*z2x9PxZ}= $/V`glU籡mL_<˥ٸ,pd<ūَ ߼_z{l! c`cy˩ >uI9?+ -4(0ȹI/Dv_n O!U?rPrE2%F1ҬH@@V/Դ`〕MvF[t0Qʪ]MERG0FI=edaNjs8"h ؋&*@et )hspXj+k+ Oa;@h8k3\Q3]2ZEE0왝63 9k u e찪ҁƒ@HMMOqE%VD}sod(FEOʏciP;Vsr+JY)!D &79gFz07qhH >&)>%dq#wl[Xx>@Zp~|o3?vnnLZِZ[3f>sXr3x.c/`2nޜ C+"PǾvA=Ѕ8H|_lB+U^sp ,U=iCE9VcRc&Adu|!=K=3aӹo$ r(uIeTϹkvջKbӦU(񧬚>f]P+`BֽzxX_7ǝX7!) /w< .ob+]/H_,BZNjm xz4>Jæh8yNٵjЇ`h k̻jA Nӑx Ҋ"|ﹰr`,BR0քcVnAOztoHMWc,4şD|d FBϲZ]"p21}]Nư,7plxne./:O0W k`XdϬ\];9$6bn9&6~tk ,a\T[#S:^=~/9ԭ)٪6Wy=S81 H7Tx(?BKlV^f[`ktQD 8;KE@jV buÃv۲-iZt\0Y @9@7ݑU/6?;=;> hdh 3Kw{'?ߝ ?uYuUͻ-Q2[j+0+Bۻ*qWݶ}^MzJݳ9[d\b:k|Frf-gQ*q0GCGn,Lƪ5h|R(G nmDϫ]h2qȲZ[J38Rݷ<:޳&F@KJ>N-XV?4WFփW}z(ʙnb=0ݮ6NB)ѷ~'9Bx{:{C|۱9oH\jPNa~3 #9hl,|4&d~W][f~4Уb; = B\!zD J Y>>>٦]Op5/vo]C24xXӑ_9-m==%o^ZАa(r3 kx*2>FzȪ|BѥH$9k_}>߮I=kw]|28EuFէ<ko?Y!.(A{mȍ"C3^V-h>ҥ FmSa`soRsQ Lfl1+xܐr:c WhO3f!JU.bp}D_c_x.cmnFsw4fog]봭`wB8*X=+ $V|4y~é^]DMj#,i 1a@o;ҷmZOz|6f&..j/:,>F,:2i۳wIiŝ-`txv/eXʥJ{7 yo)l_+|`gңDM6;8՝ aXv^R/^퓖<bK}r_$m6;\6ӭpaYO8,Om'!D#R"›Amj8fPdVjn\b~ d!4pcmO(Q3 <jgWHhqUVaF18\Ah- 67.% zX-ze^?WKr}[=86ەbzdnM/'t _5{| ?t` B|nPlIxS&R3~y{7 7UrtAbѣ6MA`,%QH-,*0@J(Hi VLЖݧ]ʶg*&0ZcLe)LpK-0E'a4叿$ 7Eu{6* uSsN:&BƤ[&bİV/xiŌRK8r™F:k`,%5lAw=.'U6i>GFd)i&5V}7L6ɢBg%FO;/͋>YA1Ck7?}Rzo(Fb6}gP ͳ=/3X2@bZN})d^ٱS p=+ &ni y0RPqm7k\k`sxɶpq9gZZmd0,@5C`h\N92"g't3%fV>$V;x KAM Wd9+ΨS%fQ`p!y䨍_<C)ƁY en}4ؐGl۪SGㄑ N8HRq42`^Z P4 kPQN4:| Eb"OTFY \X%9=PJA'VZEEosixh\,)#0 ga b=5xmغC0#.\4 Vc̺WUV/=o\'o<*&Jfo݀+Qmch]. XMܻ5pïMM^ 2~/-7Vsᅶ7cM'Hs쓰ܨd\.̄bei&Vh7LM -֯`xx%i{[A<^xT.L X::}~[p;hџR&lYQTQF-,pSllY z#-6N L=wퟨi1p=#,y1}ATOe9EtZTOq.[GO(R+|RktkK4߳4W:&e1Sڈe;Ãý᛽ߜ/6M;'GÃٛ7GWp&C^rW2:m]7}-%JKy\y[JtuE񟼶>DQ}[tF;*Y_Wܾ$k@{7 SMUo}({62$ w N#8NY[PygIj\'k)h)O863Pj8M='- cVjA~5?682լ ?$rnZSҵNIV"~FUS>?Dad>'ξhM+8z -)Z5n4oS,"WQߣiǖ1qG!ѹV$ 4~)8q8n'tc¥})dS\ۨT`*]OiuW2Ail"̳ 䤏MY|S߱b9yE ̫X^XAՎ >ywxvO?fh@P*wGL}^ӫ2BM8 ki]5 :۬za ɛ{/ H'|1[ˋ~,&?n&yVcbڥ<2LI햸hmwB+gu7.J\c!oq6a^=ztI8W􃩭:::vra++?xzU߉;sk^ ;ArÎC3f>ՔϚH~n./'}$~9%+x~]$g ٘5rK/8ХR),r0:hVIw`AGcj9HmXJY.!/AS>M4-wN.ebH2hNy=b1۹0^~v[(SlT%Ru:$n:i,%L>ɧs>VW<ژn.~awE+>2 'U|j( ѫܴ?<_tmE <ߠCC;I=qhRkn()Olp/Ș$3f*TuF${-rR0VrbfZJcM^W5!G3sI{fU:MhdY'qƾy"d yBs/֥>[kSPpZzBRD28606v(θ%T)ᅊ~%kb h(V MDL@$MqQFTD~-̟8P!ۦK+LR26waoJz4`?6݈dÛMEzQ ˦n1ᄗP6n [hn96-q?c/qg>Pzʂ8Hwzqãoq6dc?sgyu[Lq1Dg O Qmpg{ڠlBh==,iV|jC$ϋ <{yBD8>t2ho:[y{7eJm3&~:|x{Vp/$DO{NώNLxۙB^އ+c)eݪ C*Nh0~pZnF۪%SM5W) _WmnV;OO v^cNVVo(Xj-SeE kT^)  %܁@)fw]CI5ǥF4X[lJ壤Xw+K8?0֪Iu2Z n8oցR}'GPztx@rE=ewb]{6˗E=+6`5njٲ14aThi&FX i$]uf8Wƥ|aB-Yͥ=]҄1UtZ:⾕Y;Y𸗍⶯~7,|3q,7moN> }uDwOGbWw(~R Ӎ)S?4KlR`91 i^]`_\)帴o󄷂?;y.jF51+PQAvt8~J @G>ٿ ;Oq͆ ^ՇҹE}#q'&zGiAl&`Gv`GGj !(U^vMҠ[ia]*?m*>Ss m/뉌b` sHN D*MWKj$ռuyN%J3{e'  V;%ia/!neF{fIӧ̋%%)uCٲƌ $^<%KmFSY©OD"d_,VB]W亀!H8;n˘Jt3ZZj &v+,|B8үAMCY_R"B S[,eZfHzǶ2T}Th[RT&۬Ʉ`!8,|3h+ ^ERݘhVlzw"FbXzC|H$JoBbͤo@_/_(4IW[>֖+y# 9ϩ9d8Z`6V: C?RXssf| :4Iu4&g6A/X VgKN*ow*KnC@Sn3QhcB7xճu-PX7"o/WϢ:箽cStlj77/ )H=s?!b<\0߻0#:XtMHG?#Ijc C"h?zōh4Qz.~wӭ`1a!tGLJ [y\Kj-}n~YSN`\N ;PhqBzRfB7?fw5Xc] ^vq6i}gyu1E7 mi5m1}g?`^j%Nglߐ]k.⬠fm 6!Y1B`1xK~.#sNg(cLTR//G %j2YQSL>.1K ܡA43+PlCQtmdpŁ'j +]0[QiT[Aim˪!rn~V bƕ lObUg:PLѴ2: Ed1c8*阁qekQgl=3 d r3 ,<,X߶2U:dR@XHҙT%2qEI+نRHSolu5 }HtX{6bPhv.!_gN@A',im3` $9`:pܣ4Wk|-}Z*@F0>{栄yL"[P"_jU\*ݨ/O]xEFZimPF^G, <%W T[ =p=,_zJ7QIoi!*t>,}H<˜TNOݨOQ#46y(Vѥ: :0&02ϢCZ:0v%z^_Xsװ"Za"Xމ<gHsCmOK֐&SV%Z4!O@aɒ=v|'hh*{4}mY(1x*d=_|~.:,fr= s ?]$xf4^p${V|eϞ%nTW IA4mVАm6덳n ٖ6_U87kUJ5ۯ 5wec3Rk_,[K`gDo)v LBBUkVبM7lcWo3mp0uLތ) a >0!nX"98΁ K9uUvRq;׼tmDzC ʈ?j?緕4 V:T>s_j=[w?gz-yq<7WuXVhIH=h< 1 t'ge-ǃ]C 0OaWco+h y0Xc. qa16USŀ7%KuC$7cN7tu*kfFfft?q'@!yߊfP%>jc^!4ēKʁIN=YoVhT(b>K|]Z_ =(1(M$ш';,wφ"[ 5 Ug'J]0 alo$؜ onX5 uwOy3~ju1g LUiMB+_m&{q˯ztC'Mj|\dxn 'O jo%?(()xADŽba_~E_~UQ[n&'H&|, ҥ常* ƴAΆG??[ˁ->BZ볛n:޻DnKzV0&P'OLnжuSbyTQFn@R"1~t*@Ӱ-~`8Hfضj#=BRE*LVRn;gʔ3`m(g 00:b6(dҩxj?mz|=(Q񉁧=+\jD0@htdse&KYxqny$U7y,=Ct&#$JW#QhӀCxP$0Li¡Pw V& }SptJy-emyme Uy-eI7ar^*5 nք~k KiMX^PJjB\%؀Ձ& @VNaWj;fm.Z(_L-Z#PBm5Aݲ|R$/]w?"o$ J) ĕ[-=G vn*ܐo&;vg'g÷GN[_tiEr;y}sP_wWF{4n+,ؕcxl'cx#{ϐv"֨8>Y===ɱ>H?Z&VbMdU4LUs E4Rk1f*+h~[,Jݑd*frU0&Sto*<+30tIaGOG|؎<؂܅`(. %jT "|ղXrAD:Dceoda&#/w%|k{.0zsTQ?`LDpiKz).|'CtƐf8;.bm6[¬k שJ)}.=\XѸ_nP85s_CpYŸy[U$\; _B:+S}&Jُ2t*06Jr4OPg~c{#ȯyw$yө@8զK5e&G l:O{e+4pb֑yܷ7)Uq6eAό "` `CLWy׌O_vM "bꠞ𖣋YH5Ay'Whm4NFŹ.?Cf[9^x6ոAR-B~; K G 4V/y[qvfWn@)'Vø ̓fB"˩n`XT׌ J0DBbE_6uօܸ9F; 4."A\E-aDغLk8Wo876ra2xMN!CTD Wc,Hu;ئTMn>gK&٧? m*~JV^j ?ؓ5G\'IӡM+ fU3H5^7@6 5 c%d˴#lDb]UAs sOr` H}-jǓ+{9a@7 _f/(32+>Y!5˵H ϧ_l'/Ika~o)㿔t]2j~nW~(Ϝ)U|u5(EB0MF8ʇ6GR[[1Y&c~?̌/ho:O-֋6v>) t>اA*84Ri ߤth;զʏZѓ\g%ro`!6EVf`K׃~%;Qp_ Jv+& jE_o=*qX<YZL'-OfTZLǃ?f}qUS{ԟ;s||$ϝN4t^쑱ū+ TXOm2";>5 w'BbhM9 M8xjW@2R$h+!!3x\4SE+x}{}ᏽk^^֩/cf{֔CfUD 3\C N}T auy|{;Dpڤ[ ZYMWLo۪To!jfm!Ĺ6D+d;Fib5{I1 OavqM0rحZ X.S'˚< z!pP-Kh=c& a)Bj-$QDq 7}죏+xe޲7ncB[I95Hҳ[BoS:6sd@nŃ$l8%ƍ *Eq-XdH~0BlR2^\&`QjaŬ4ڌڊʸK!󖨴XYVaWThҖW' ?\ZʵS'KƓA,8(\[5j:/49ErqCӋcs##eSuJhe.5Y7J4̪Yٷ4Wȩ5;B?jw>S726$Yn2&בf4~}MH$, @< '"XtכjBRڛJcj"FoWJ}}sbu-y%+.1Cq|z|vc|ڟAL%AORTd6cUX,WùeB*?d~]12 cL7׷D_4W7һj[\IzD/SM=j"UMMkjL`SkzŸt&폴I Y~+xNQ4N%OOn[mc-ʼnVt85:ԋn å՝Cj^0e2DjK4!;o+$D8{?%;UP?`ʘmHu%ˡNR5'?Bٿ}}hcnO6Bj`=}ffAAo!F6ܑ+ԙ;4(For >Sڸ4GnW׳b!,$>X ? DjX(EJwά6o vt[݇Q+ԟ0u0q_cG71ߦ𧿴=m>,W@77h_{ܷ-%v_r֜X[l|Ԧ\usyѩuYg::;ϝ *ʠU 5v~wp) Q*Oސ~E[#j6`!*depH5Y5Lq8c-^/7jz5KU}/ Q'U~ίժ #\ T('t? ]FغWY1\cV?0/8e#8XWqD*G?G5R2Þ5x#j [z-W\K}˞;ji0k]풂?⽴9p|_By5GCjFL]7@/TRoɲgBϻMLzF^i6VxB4jIzDv&O UN*[Xũ%_] G>}k=B'gM1 [(3UԹ~2⓺hHُf<2aY/$ a 8@{<6|R-T: V/&4tE lf 4dDixz!.֌$QI y N4k 3iTA),󅾊PTs2@S-æCc>&,:>=K- Hbt=ǹI[촅$Ee }8. M|ptPSa:ШZ}SĠtQ VkT^ط!3S'Job2OU'$j^;F2%;I6 kDvO,:$-C+#eFtO*%<oOi㠋n+1Vܷw^j3c7Gމ/n$lLe$aTv b1!09zO.:D8+350Zs&]蛊('*^z+!DCJa^ u/] t.;5Qs9:d%zb5ڬYH̛!%VwLh>%)"5ҏd~2- TݣZ"w=5~3kj&Ul!#9Xpc'VPK^W)\TE[ԪRbRQ佾*ihH2e9zƉ@ӡvm% #|`7rD)q7*aXeήtŵڅt' ]G\%OOEiNJ$ Jgp ʘ_8h5w?oUXLG+RMu3w6KwM 3f]P5vDyM*VteT C]-RA5-1* \#SzIWn97U[00eUu{R\ʱ? LBäʭ| _;PO&fN$xA''~w-he>Pҫ5Ig$W31zcBf`v`俩'Dw>`B`d9< ?(b-xgrN,]PNJ`-v\̾JP, {Gqjn= K$F% hCmr#%z3NdX=S6ي>UI]^oJk $: MEhMgę5WH6&e}Ir姧Op1rn*FۗɝeOv1AZ_m\;?8S Oƞ!szYF1P[կDLekY{U,\7J ucL=q×{'>-ڗ-L8E Bvh( ,ʊ@¢ԨTfLwj9+Ț+0%Ƞ%b`Ґ+SM\rooNOTNA )׌ Dn4pƏfvxe J0b m*<~w7ƭߧ"#tK%dʖxu7r`aU ˫%=gCK*QP3nڽ;6B&윇PD\A` lq\'-6!v8f[ lY :y,>:MsgGܫݏ<VL ,-8'4~!ƈf͇yB\(*xja-#dq1IZ͌(caV ҳ Je.Y5Ꮽ.Lj h">D3y>utc"[s-m34FEwl4 x $aouw Ɉu!ɚ o!iD(M!]^k9mmH{l&hюMK7{@!iޚFƥsCb]}hAd(i*Z՛: E䀮q_MKݎK7Gd1\ZE^W 6ڲ&6s4WQ\:f>KI}W۸sI)=L!tXY$.hλCġuϴ֖uŧo.w\NgQva*C{̓tM?@Ao 3wܪ8&U^0YFLͤfu\yxT?_ӑ''"HUXaQ|<3M5@jmck K>Ä5 'ILt]&~O<]xvƑ2$O<ڻI7&$bD wn -;߻'PΩ-wX*4n"TRUi*45) -6Lj`~v{u7ͽ2W gB w(9qD8cH- (D{?j:QJOa`>3cF w&Y̯0p`yI'8{)f"i@=p8 NĠ V*կ=q%@PYkܹ7y t{H%:D)6'&#gl {ϓ~!2+B__,8*8.xeMMCIw/ׁȊ !F`JHid.=شldj%sg.ׯVx{$9/f7ertszO"@w' ewD[_3d]~ ilfW=SE/QtAFB"3?Ǝ+c}=#yR`rQ,xXn @őf8F6q`QJ4_ jnh嫽ZO YƟ]PW '2 δ.<_ " yQA헿;|L(V]M,dNJ9' \`oU).ΣT4/`L1JE%LiPnqfZy:(~]%(yyqyy5aX~D.S`I5La1v&"UdHVX ?5zdGy"z-ޛ9ZH䀀j/"@ &䁷~څ4$ )7 5h52qUM)djoWfh0~2Abx<ǁL%XP'~&ax䀈"~ b-\8A$ک.v,pBi`gZ@j)vb,Z+UmnSZęjL Dl. - r_Gj&rONn9XͺK<,<#:%cU ͉ Ji4/.m F4MaH5Snh5iZf5G8&[Wnyβ]Z}mj)2bhã7&54? j^qI0_teS$a)Ia%:3ucqEkxl}6Y0}O;`~h)jW W CtK4{"x~ H+Bc!UHy'޼Ÿ z:LgZc6(YUm^ǿ`@BE&0^\#h|`uUnd;_̂=M$ "J'Y㧪vȝHXZ\ ;'bg_. I?4cj*TU<ySxJMTdE`z ^v="+ӦS޾m'BXP,o޼7,HdZxIhE k!Pv$M[x YS⃣ dK>X{ \Ec'PwZƤ_(i#W5,#vcGG߽DE҈!T~QE/3TuM7yZ=4FF8#I0Bv,C'eN'U)nȪJӾ 8%TK$ayLeG TC^>P24`O .)xzևٸN5M,#.Q)JM6|O:6dt=謚?іsPͦ! X5:,lnX93mۼ1~ӾS+ aD;<S;ζ?.c /a*~>N"ol<y X3524{+$ 1{4(NQǢX0+ExpOPOYLN:+*=+'{OE0|81͋5{RZZ+ W 7dyOKxyL¥S͆WN9P=&P9VuE@.e~3/x"4fQovV+/!62 R@_nlW B5XkwՃۖ&Q~PRk'{P 1ІJ#zV(n:Paؽ@S³3|Umj&֪ު%ZEY~ kmP-$u8xU~A+o.2$)wSFHXp8NN؀U,cKtq`٤~B~yH8Ű0?Y}hAq2-k887ȫG9R>C/MEERXsTsU-}AJ/Fln\f%dJ4*=C!&`btl "wQii]A}5֔:.z]m*|5ʣ_r{kQY3uC];aaR`.(vSYXV^<Qx}pg[l*\2u:LǁdVh$rQ{'_:c-2a#ctc7ߪ°s\^oTUFvhq'Wa22cuY, +0\ڭlMphS Y-h>Nۦ6`7+SY^kVEaQllc^ʖwH;+`9BzK`s@ 3|O1h^_QZWbj q<\ĨK@ *m&VPA߶@zϳ #]2b!5W4;#mdouHHU+QhSUd֪wI* >i,k1[-\4qyN,R /Ocf[/~ (D'M 59Zb#:9tn^xD2aqY '#rc>B_H{G 7NE͢җ(y~B7hFz(ӗ( ;Ip;|p%ZU<+i#'FypV@yCL1]@ X܉<|ѣ`g0YDmжFD6QT=NB[+m1F'FӇTh[빩~įFo@Qi}v ZCքݵ ^.]( ?ނ^7:%&u@2y3 mwQĊ\_z6.ckƣ85t6uui'H1HΓ}u|sk_ru1\z( >tu-OBŞ V&$Eq^T"Y׮auuVK,Vlt씳AŎÍgZ1@-Md_v.BL?k_ `:xZ:i9Rze=NGNSX'SThN峿8g Ȍ,Shg -f /fm&4 3#96:6pp#n0|v&ЊiB$>٩{FBZ\X 7LKik;^m̧VX${z/4l[U;w|Ɏ;:-;3t iᝓ1&)=& E:jѲC K!X hYWv =YS&,|OOчQ2s8+Rz$Pwjϣ6@1M?A(Gmp:(yι4"Dzr'9xCuCuCaweN,h\?`QpWULk%ǟĝ3s0ƴhևԽ򙻦M'!Olt *iF̔ZZn[- TOA38:۵<yԟM08KM&yDOEVMKLI7S,6.v>A;Sɽ'A KG-qb1 4FA^*&YX n݆{8SSMltP55uѣ^`Aye/~} 5{Q2PsljGDz}}nNǃxp".gEj, gݮgsJO炳{a. )٣zLR G Vܪ?zҳrл»kMK 0O+6Z~…%b..~^ jL}cZeYWג;Gwm yݧ!?sc uCiHKS^U5Bi5Vʤ"0B<-n`H߿R^26*=dž""H SD"EHc d?c2.Ei;ù~)תleUƾJ Y7,]w&{t@7O9;qx܅\'hT:˷v 5Rgz pjźxjn'W!gB ^sM~^M2Zds 0aqE~o#1{_gBzT4D:8jw$z{a`$sK![d}=bqn0!j)#O"Z%\n0)tƒ/ 雐֔PhQ7-"L,"+l1YRSbf _ ),_5aui\Z:OrYs2N[H/Pj:o^/K[QT:n V[`cjzO8P_\Dw_o`RFw l)+AQ`ȝ,$&I\Ğ.[gVt,j:qSJ//fJ*rP&S vBEFP~t$tFHqN7a<cIsu^L3A]pe  '~ä%> iTB ʝ;to5X܍+!˥ uSsb<]6Ӄsc|Nt՛r[TCy^F*ء)H.;@ +Ky} wX(BNey9 'ߵ[cbl#]9# {Dc,qbkj1O =)BHF#lhvxM;7T&q4FrS_tx,oڵoOԴVh56}IC~oפ=گb',[p#!ƷCbF,BW'Dj0ɑ*X$#!COj*>䷯ÿޟGݽN1\uÙqgqVOi͹ vD۵h1Osr!15XAsTX"Z`2fQf2u]i$^s'R=ϵ a lK{{C%%$; S"nYAQȷ'eH`GUPv"iF$];R惬u6ZPb OwFR# Jp~ '=Iչ5=&`m ´gjP\cwCcEZB%ZioD5b T]>YCe95~.n ]3mB 3.{aAgg.Sz>x^8ݙII'wzro8frO}BZUᢪf3V\Yo~~PKHܴpDt- 3D!lKѹV'JGխr0*'Yȥ^4d' |<Ǔg/Wz#oq7]zCVoŭGt:?`5kIESUT}7t_سٙ~ yLlw|6Nurͅ&`ӓ܀*86߼v]2Z&#^w›J@OV8]i/1ZF~9;`OFR o%=mDH>^_1 5TTZԘٷ>φ%Ǣ|(s*Ј>I59>*BFHgP_zw*[M/X'B_c?@ %4I-d,}` 7} 6߾nV˵ <?xj5׿> sJ͘\ف:7?;fW!= 7genv-!>[d^nRfսNayaVtAVL *d I@EsVO'eBj*_}%p km2/*4S lxzbħQ9;WlHGNq2KPYR"dAFhciJu1)EZ Bb{kC29&߽˛2-nS&?"ē\~^ zgv)YVգ,q EYG-jR9oB O? '+"CyA-Zgy=-TZ"QrS8jz_8E#7xq,h}JʃWmw7(ݿϐ)lQfI5#[KNv׏Nl=&9MɚMR~9{v,6P( BUd\TIAG&,ٛV<_Γe3-zf 5**=xHi^'uY2.'Y^WYUd&IOf%|ЭM2N˛.՝J8/YaÀ`eL15@`lk׋Yz#Zp;OIl'dRtipp vHf%Mzp?f{7S\6 m6͌bx$r<%˛dg-a Ʊ5>iE"f_PlI)2,.@c?!B_Vs&8 9xKoiB<)̚d7?c>5d4xird;Hp~dW:wkŖ*%GJ^.fjR pm6F^,/0EZ 4E zx^6fI~[_@0aoÂ>K}Kh$qDzcsfe`i*^#:.놄nY {zl k]/ pɢiϘyd6<.YdqԌ6R;3ߨ[}S7 2P*Ezfߠ% @fb^,l5zԴr͸&fgac[Ŭ3oټnj2(<)᧬3oc,;(Xqc qPe N65gu$Dte7BcgɣwK1pAS-xRp8}h6+h~~z{n8?gBbYO>yVeh.YHauja+fOقmBd"8\A@ uP]479i\"x~"oaҍ54ek F'yU7{i?xAbQ &Ib~Y}\@:1)dU}/8p4M=hCY3CfZ|PUe Iz45RKlGއs6fL`y/gl`7PeM\p:A p ڮB}ՒmK[h{ZulVyFߠȋIy\t˞"GKk2L[jR9 A7xd0OO9Ὢl'T_@@\[c/aFf^I h4K'Y%pV@Gh:SꐌG^#Lf>~H"I+}VatIUV]C"^֤yn5 G%Ӑrrë m@?@fS>viNYs7,J2J,-c΀,2|A'1spS.=W7=I7װ}Du{xX!g~2#5%٧ŔkhMD#mgx:xͮmCq P& Jo%xPj#fn"ՙNDr6a;?*sxlJg>'`Ou DaO0,nCД挔LFlJȜv:|o;qmH,GRH_(PQɇ+ls||zS`=K_<3.4r6-T0JwC1 7 f+kQL24I~ivƆ+DVĬg%+unp7{~YOj'8l 6V!bSEy&F_5F'H9\%(xdda2_OfuV%% }M&E-nb'Ѣy;**\U沽k cܓ^>:͔8JbJXTؔUQS!qIVϑ/}l=J~Jp%Ic<#mǶئ|w\FDT4-/oE:ѷ>I`JlBF7Lu٘M"}b;H375`&0Ͻ;Co>f8H>Ӑ~ZC;-?X|.VZ PJж?Vyj@(+ W:V,V1uB^dvGlOS#NVo-_0Qb j0ho6` L<&we}B4"GUV]`ѪRi:NE@w -)$dǴ63_O6`/qZE{*noHvw$H*@+~惟K8tjޢMrtve|%]*cMX99%{YHF- hݑ}7EBDnإWgn|d]o >Xq`>GuC9{Sw1ԞEXpEkdҎh>y٦#p>Qo='.ņJurJCfdZW˜ ZOz%;,4E2+M-&L'nA+<`,HO Na ] |#5yUp( ;˺ a"c9_lp1'T9L$K.fk( [}8۞=ISY&>O85&uN#'$^WG`P ]Uh0پ*\iAM Fx|C<0NW.R1Q>ɤ,z ǽdEg ǁ.# QyILz)-%1![)~AauQK{arҝ$&܀K9c8\ 6 r ta/H}a:A'y͉}Yh>Ґ>Vϯ~vJJ1s} I9J?(&jʃN<̐NNn6ONG˙JУ%aR7:r.ƳI%]GP>9s '>QS kn9#[)VC4@kSE. .U6Os ́XJo S[[.h2/ђkZnJy0 Ý,fyN4l}mFp}z CMF;6uSSͣf{@*ZĂ3N l|f=Nؙim9#Bn6-:݄pK:$>\m5CfswvXngdN_ONHSpt%YuHQ /3Rs#2Œ=0s%'ө cB&`Y񲪹PDDk撍sU(+d tvbA3xaP i F3aO4s=+A,c0 ҹ[Eχ| (ҔKP(fnWGC%  {q[-"r#Qo #ya"1#}GeA \* ;r=Ȳ a,C)ݨ9o7!-/ުֲ6J|bi0 @iDThAM+ qyU$,aꮢ ni;Px4NH-gFM|c! ՚PƳޑ[mAB|CmٖMuue)*u HQi9 4=]#p7Y8Sp|z;}pPa@Erf<"F=cℨBL'#wO"J\iQYB oS(|_X @c1vw25= TbQ6T*~ptwJ ":IHޢv<;:<9=;ܧ|kk*O^h`K-EL. +'+Dk %h^- l1_dA5j+ ȑUv+ݥWn7&D}a2&qÀydQ%K_R3[\b\ XuS7`Y;p)yxVbK RMa@A#c;1-r=ۺaS4(?,Q"|\wo-%Ŕf3 q;rl,LV@VRf]jP"ȏ.Fӧc:泇7Y}5$ 5ȅ(XRr1'L@O(t@,l:'i1аXj+5L9BP.1iwMy#-<@k2ьCMO_٫^a 2YUv5"UڥRMMfe9t`*KJtEޛԷV~6Wm K9 XH!-[ڵɀ{Ϡ[攥Oɑ+TM!G%[Ѓ)6/rZW~)"X35.l[XJk/\9ž6+D :l#-|q3#4.{ħ4\L*MQs*ưB2Zf67I ; ꤾWLIg%E2zeXITP sD٫BȞ0Dߍdey*fw6^w;̷wku'^yZRwBs!V",oG\A)..PohI4H^ftaOpFQ|;H^Q۴;-)m @w̚RrMA?p9nu=G<tO:($.U`=1ZO=hZQE**gW.7ɵ4ukf!qVϢvZ$4T?zZ^?*Nd\ۧi*Ƀ++X@ =9fE$ Ni3CR*nHuE/*3ԺRɣΞ<~qvma-N[4#w>MMiS'6wotUf أg~۾H7͍.PE;_7naEҝErVAڞ'gN^?{v$dj$qy C=swlFsfjkW%X[;xp|ec"T^>{?'E&Lsn^T%>ԗRVJ؉o-<n^jM |9h ˬnJJߞcU8^dƐ}7 46e|X_L|}6M@3&?孶;]T6ϴ)9LzԽ%ELdJbdZKw"*Zb__$=lB|'CAwE_UĊBsOKA8ad\V)[#GaqnI^?|3؈cۡ$ɄL3>L%E6h>n7>w|w橞jFX?K7F:1C;|_ˌ֏.mHe3]FQ/8M,{1YcxbjƮApwvP]ЏBvhhKfW?}CZ$<ϼK#uD`r+ٝCy33)!RC<[]եNO&ŞJ׎Js(AףI̷d(Ld?o>zjSpzSyٱIom (;ӘsR^-A+ZyWgT2}6A;1)ŭN4yl1Sr+Z s_Ԩ j|8ӵV<>w"niiT)\U`lⲤBmRmn'ߐ/{_4ChDWX-"*ֹ';?2,;ߨ;&Y+^8(6[58sY6Ơek6;&9HWz%U= Pk[Ou-ր"?\k8acOȑnk[+Vk]"meZ0} KQ rXWC-/HCw r8<DPwӪ‡cqɡOZF!Ʌj)ndؕ N^aXqQwoOg{ObPF KNGC>;<_͍ފ6NRI:[zp$JlVx~jkm}YΘYݷ(o55(ť6\?_n0Ch5V@0R4׹u&A_+uwm`xk/зoĩt&laԂ6ƶ1R#"{Y(84;_YC\Y fq^ESdgV;R'%t!k*k[bRJtdR'ڊvorY Bˋf|iZMߵW#䮍psoHX%+9j; \}Onm"Iv0o ߴA M((aҸ/}ݝLUeڏg?,|r| "^u(s9ޫHj m4,].RTq3y״)1 Jho1;{zq k<+Y`}K W)pbIwn7+3L$Z1W&be!D1byU1~!Cpb%sΏ߬*F0d[1،7bо4tm;yA[5LBtq1!9#GY5 M3>AJlg)3>==}pAn9i,/i+q0:| /w `I0IOK^be8b#xYh/wiAéT31_G"jp[4)rnc-]|9㐎pď#l;jb{"Wa4_Z7O'b4 s)10}y).qX^ͼ75 CU^el@b6E=YPql l,:6޺^o;sxg~^WYG2e 6֤ҤtߢrH9ɋ9%I^.pdJ~Qo mrU 15/ #s7Yl!8ľAKգ @2l]՘ISfXPtoO)lk+]#GDYIjouN8?akd˘"[dzܐb% VFH^&E`;D3PM)./V/tr@ef`j`5P Zdv3{"[ﺞb׿$/IAm$/e+*]^dc,̢&utrڸg6*\\xi}H9~T :[ɾxقwu#'~K;}.\,d7 l*`coBP%!(@-.6Z~{V w6kQ+BRyWSFj&ZeZ8N s G/AAo U۞h8zug4#TtNoлZA8jm#3l{8hIVe;nVޔBu&~Y9ژ?duC_WBzl` o4]N[NqR$^gܚ(8l;)MX0 m#߱@}S1iS-n5 z`sjEkswe.-A[?sWrUұcDN gv'bv>~DZIFQuZ_q0aܜAn匲{>T(dx$]95,fЦɼ!$e?8{źkUfgSz?zS~FJ6 r6"Bi`ꎆܙM"?n46\1Hx':OntIj$u;(yu]߻n'D/y3I %-L">~`+# 1*0PYN(C7=JXk9()zlNv 1bZ#*rv&RfQaC,]e7^2kY!&cy%ds\.#jh#Q7/Q:> xe)Z~d63wГKd`b{Pc%Z ^Kxt{ް7P hM !Ƚ!ӒtYk#)G m'آv+.]PqjOxC#Aؔ+1b;.LYrϡIm{Qr] !ZC+iZa">*jvQ{Oo#]mNvG[HFe_ڬR]nZa ; kVŕNjUik͏ is;thqW9W}vAl'r%OWmJ< H&\+GZz߇A"`؎Ʃ 6S[b9?ǰ`TQg M̾񭢩moȿ^il ̃H\G 7o!;:=jw?x6z/ϏySi)1 dRgq^݋ 5(zezSRAׇѶ=/.. i^gi>Ǘ4M~y|؛oųAZB;h >L(1"8 O1,5." #PXz9 aZWu݇a]+"&_W27L AaUZ|p}o :*=ċ8>::;|~zݣg'wyR7M21*H2D8 ķv6SB~^p,$ֵ`$ӣӕ_ ²ފ&UY?:h/Ҫκ[@l?;_^+ b{.FQ|G,"&l*y.Eyʛ'9EXMU r0.IgyFaz,YQOlhIT8Xx1S;f`1~\kO `d¿w;`ph،廬Sh 4a%)3/js\GW@w7'\\tX$dRtmLh3,&3)l̠~5SyQ3c/&@P`ѲPNhJ@#PMgYX8~;Xc7_ :YX!ې1l IҸ"+BaXu5*^*(|HըzqEe|\czA T@$[q~pbTef}eJUf.qt&wAOߗd9xGx{w)Փ:Aa~]طwIl/ P*$ |ʽt䍖"J ad9|]XMu@o'o 7J8rgΗ/5KYl\ӶCͶQq`V3 KS}w_Dg~ "O3 AuTNn9ikY]X%-nIZia9B Wף!; BR|M#2S!+#Hc]"vc*CKBkf5M$[(↚ZPMB> @\M70J4R{׏OUY:fֻJ4zWqh_@35F6=%OK#X&G*|%Ǽ˿JSY" 'pHOXt[hzh HDQELtanY׺rH&?D!n.F?<-.9LwOd#EBۗsfV7>A_&W78"orwiᵇכ 0Yuҋ@Mo Qdlիc>6;tBݝ6`?LOP15E'UG!kAـe]mQwuAKRϝP%I!!/Y<+$ƜC?=.|%gj 5wz3CssZ6"נ]48fk &"a=k~]\d7q*_Z-\}Xb$aCO * jld3 ՝+tzS&/egOTka?H(Q_W!^lEoMF ;q]@َa\k1I;C߇QZa&L;;L7K|w8m G]}W#WHױҭVʮȪ~2vS/zM(^PeE4m`Ccwfxψr1gt /*yԃZbM 3]%fJGP t%nr'-LӚ-xM^Qd#<Nlv'IdBQՉ< W[qkC'&`bbӆ^]%e +x'm/KKL9<%jY1Quݾzf-/#\l ȴu,xuIdrޫ[]d릟E|fl(,ŭI͋+A @nԃrI9Q=M{|ݑ.rOS'I$#r|`T5bv?:Ztk)Ly )|z*D4a=Y:>(ƫ2AHZ8tX FT؆ 2%\SXn.6zŏGnjo1b?n0Il G`/V+VN'q$ϟ&Q|v[Άx?ڴGsޏ"fT/첤?7cepQ@>tDB}e@V \R;1,/yZ-Y; ")8:}+E:8+raE)nri3xOnohֻ4K~GZ)VS:?@ [E6&wψE`*FQ3?x=?ͨ{LS$ xݪ.[ʈ•擕5!]CB}lmZ֢3$8c*cد߂=P׭r@wzJYbvB 9*MJ2*>mUcH/Y%Ifq-cSMe`jT psJev)uO糴6uKz dRE99D8U1;I5#? <VHՊt׺qO*f}zrf0{fFAB ]d)5(tפu\TL2Z LX\6l$ _ J>wRI[ <zML# d ub)WQ˫tbO.C2iJ&+GX_\N%Sϟv3!oh%;(_<뫃ӳݣß$V6b)Ǹ@By꩹鳲䠣#= ?M ,E9W܊җpꬫodNk'YM5zv? y顧%5vH~f3M08m@ⲣ᪚~u7b,taᡃl#tr%?XzN1 |jyoer`-Bj (&c:J^s@B`eyKe+<Ç*>nR&{hm,M0TXLt4`G<81'5 AGG7?wz񑜣\lw4A*g6J*8eΒtvš T$˚N&n-KqU- *)NMS|o1N)`&Ti= D㨷v'޶*ړv "\9 oXL"qm]/6nӒT{sykVoa/ͬ*%_'|;hkd s)*؁n1O u-i)e5Bc`1h5aGu<#39Jw z։S4=ar{folr^.(_ͪ-=&h,oX^߱SFjx7ur<&ı20qj~se {8fZ.gXZUs=NwIgP0y|~yYZB\3֞oHY1蛶n FO[J!K2zF a|\Lf8 4ߡD!p_{EAE0c=ϳw ?Rm"-DDv\ THKR2evJ9Ή ?:LϚg803PhӭGJXQ*zhR~7v)?YOr;CDqqΧ>]O^O`i|o_!n!ܠ͍hߚ˾GoqebQ5l4ZҨۈ4Q:&uL{^7+4~h䌉ndm.eCnX֖L#duX<*{ONWE*&Yb9<}Yhɳt/画#cwG|\@B瀴B&Vr8ܧ0e?̄lwTjB&N2`5٣H&Yu>F^pFbVIv#"iwa#D:v_)G&;P$Q#%fAW ?!8 -3 a- G6eKBkȻ;*-eϡ2*|P #ull|ޡc(bO^XӗF툕hg)y#;F;2rJwIxZwe[Goc:{LlmʺӁSj-e+0ֻɯS@˘iӞ +GݲoS)G]wj+,.鈾lġ-*C:4fָ*dDyGZ?)[,Axеy(ֹKG㛗 x) j~WG2KLU'x~k ?C~4H}j~/{_,h~FJ1Sݰ{'O콣(nk#sv0cQ#_1  {Gy@O?˦;G07?H:,[R"-@?˚PJ}~ptS织/ +lzr#"_\d(a{}C&CGZ#QRf1bA:QCp8Mvm3ٗog1h{*a^*Y$ļ)i&>c@| '̄NW5tsaYmB'X<L?.PcgʑŻnt#L57@(A.Dx!:?e%۵2 01Fɑ0J4kizDn P]9-8{yGO_,K?H47?xY5^⍣PhgZ;q/y']<ڹC E,}rV0vcBAg7}ڛ>xϲiDa$31ud>9 -h|r=Тq/9@LBl]$_}\7FRqdD7LMϲpee_#Gei,|Rʘ6 U@UG&RX%HV[MK6 (ׂ샖aA^`HOՓ؈ I|! t\4eq;g560j? |lmQt uF7".4:cSPMaLJ*@]p`&m^9(F*/ޚ$c0!aK5|zL>7T)<<|^7SFa,[oQPS v ~x֮&o3Zn+ݳ0+?t[O@٤ߨB ;3$K7ަn(pI:kn*1|L!G"c8 &57|x~'~+ގ{yfqUĿGdgA٧GAׅK'&&EHإ6v7:>D k`LhxzQ$1@y{st}@K ~n.WN2G-NYTPZj'ɚ3MRL[sp>NM MФ/~X?W L 4D?s՚ȨB0ۜkF4_TrmA-I.Wt-C}R@cBlFfU& h Lj \,іa=}vpQ8kRʤ$' h,6FYd3%qeILNcjϼ[aAj`4RLFO'9í߀})_Vx;#2t{])t΀}ʣw*+=ų3cK~? - Bh\e5vԼУ)J*Sp͕ ЁD&SޯyUXQ,x^`\syn -r|gRaI6(U)*,0E/ʅ-P!A"ŃMsl{\ne - 1Y|LQ5raHa(3مq ">[~7tgyШtXz;D'pY%@rnzqXSPK1 PVNxprZ~V]RSlsŵN:9:u?9e, %HoдjIH2NYǬ.'lKDG$tdza+$Rݓ$Öw-lWw@xbg0Hɿ XwX\=%#t3(z^6Tފq9ܬK :j1ԈOCCۡYVffI㠭fDZڄmsG9.Z#VV'),26,vT%`9a|q˙Cj!x]RJ!iʱ/Ҍ} _DYD1Cpwjk#K88,G> Q^g}ķ+ >mm/jFi^|}GHT5wH=(VDûS$5Uh!#Dak "B^%1(MHbM=pc_B^.k/cF+߳)KW`鮆S"+j844+ P:Wv2fwWtM~L;d8BM!cw؍tf;R\4Dxu"1`CqE3+\A}.ΌIՎ]1@ncCST;;se`;x[Yh^'1TC7xp7`@˟}Tk- >vM 7 3Pi7ѹ&$tSb mW/YuI,O>=P9C8 9y}?}Ё^o}nzrcU֯KKcuI {~Yd]Mf%CcY<?{~I Z0{ qv̪fn1+G|^ 1u5o6tb[rXD-PՃކQ)!O,rdϠxo`N[9is<{h__~4}-@P4@W(7d"P&3?^<+A,и_U"{L$kG%MLh 4C9%Cһ&'Ε 4ş28m*^${7Y?vz#:`[ 8#v"!7N;Ķ]LX8u' +yBm/F;,g %"\qE3\`}G56M4_eq*ͶG: d7r dڭ8n߷,>ٓ@MboOtnxoK(S2P閶8>f#/kJkw:=۱evܑ]^y,?>{ײp}]b mHIxzuezSsR85`.4,,xB/7M%HMśk"<`ͲxυQm ZC:$!8_VRʞCb3Z|h~bqwlŋýcL}~7)5˃giZKp0=.Lt罽 uKv _d7ܑ!jr!"WܠRCh(2J `.wA2=}vxoFbR Lw<)HuTx}<[6 bi I!>qc>&,oQTџO3s5Bƥ\Z<5= M7BS2m3)W dlH->Ǡ ; syy=-Q2JRʐ  !2LlΛjХ 6@yȒryCy!eW`gp`T胵~NALir$ͮh d1oZ&/o^S Z-p$ #Ib8*2)yNR.̀bxkt{ј@3B/j4̳Fr)Qr\[ Ds^{yĵ5,*gXKw¹xJ ֮Wd :eҡGk*dvcQQ^ףh04 wOM*YEZض0{VJ g}I8CYu.ƽ=/l3iG=g*2T]5W3³þ #i/'TKa7Bp2)(m!fжf3{g3 o"(aLopNQ$GE;U%"z|7:`Rܢ›M9zjfƱ;=Њ(2kưݻzM]N*+nFHܦ)}B“l7t 1'-$?|N3X)5x4L(a[}n Vmm(&/M4o7;a`9G9*A}`P510̦-/OK#av 2떣aM$\fdYP)-sz5'DOU&oP1 E8c W#%%KTѭ"~OLu=DLr BJCK}>M}n؏C3Gso{Z^_=f[x ŶgI'vBmKǘ;y8]#F!quT{9f F>>q,3+c3%[TzPFʍ`pasƳS"%s$Y9&YPW2Mjc~ưmz?4 gQ{ O! 6hs+%di!ϋp$q S` 9o x8PۑG, ib_GŽ&s,nQqb)Oc0-t{5 79 C RCYgVFu]#fò{8}1v OkWCf Yi|m"-딙ꌊᙐE)Xeϳ _F2j$,)c9S ?~4%mV~sₑ R=% !+oM$&Y}lujGjl[]ę]PKR+L] O>]#CrkK A&(gcT,A41J]hJ'%f5qȗ}u)"U#X$r<uKDuNo ;ҕ a`žz_2;%]YN ? tzTCR1 %5ؑ3ŕ僰;3%t]p* ¥k@F,H`SRKk'Zgq'迩8$P}ۀ 9+>M]O`ӏ8I.h#u)+?cjgE |HV ӎ3jw3̍QeƴAz916/J"XVRM=qi>dseZiy]D )kBZ($V d%p-,s`=PLr_\'YtS05*Ul$;i BJ$ qvO}c)*&ZRܔT> ˃y -hd$Thœw{A(Blgv[ |"+nB#!U'b!yb}=LA+)'Ί|5z| Q誠f[UANK%N f@+D ~ t? w QAucꙁNanōGĚ!GMp #݊L Q0T(b#g;2La#TJ7+^GQqÎ$'󜫪ryVOSir386̾ͪ2a2.${/O|q!v|kӳyN^A#b DPMl~Y s0De +vOgnrֲc:>=N^-eny)߇89' /13:)yS]\,ʔ Jl}(3G `vۈ{vĄup:"۟"eљ\aS#/l_")G W(sI,-<ǀB 4)q2p<-) SmHy5mzDK73p`[ 'UnJG6f7c]yNR**y>NkRq-\xQѡg32vx35|a%Xש s?XdW)z +@ٳRX `˓pHL0ymBCgXx̀xQ2$؞}-U&n9;!ػYKrr!!p׭&=QJy ^/ ߪ甜m( CG(] :4 Vɖ|1u1x<(>2eƷ΃3"3.`%ny=NIf#4G\h2V}Ee0?MA MM.OWLܕhrY؇$'(mʶD\k8'=TK F:kb fg~Yk@H8Oi cZ׷9-{a>mE*G dР$pSySlcsp^3%ޤ#B7b2LTn5s=,AeNQq絑<,E3|Է&]L/o|c^Y-,~>EzqG#a\V-!Pa+N\hKSK8߶.MoFW+|W* י闐\:k&!s>K@J?QT2,Ay˲htg(X*DVx/#z#T{t| aG򾾽} ڛ'"$Gv2[k4{DLdI+ɞz|ZbK1fؔe%w? *ݤllf7܎ oPN>PM(qguWקy. }պ/[A'pJA{{)qtjmf%u|;b'c:V"ӄ;EWO~өO\n Ry!+xxd#/H[|xðQ6U=[ذIn_mG:Zܝqy3RfeK:2ѨrEuX6xv YQf3];hfr<9xBcqIEHV[MuznvG w UIs5 }v+b" *Q# %ǣݣ݃ (!T7n4Ha6qw/)M֟-'5ZvWmQn+' EՖNDm9$Ꜳ9f\c^"c400LML@hp<87Ut2Qm*Я֔[F|r]]*J@9e9%tث{v%|!5kï)BX#`F;fr/PH_#FY] )گ/. `OS)6 zޘK#|+/Nߜ=btsjR;a+%BVM0t(ׯ-E;JGnQ (-k#␔ީqNΛ _:XRR\IzŸ:_֘>ŝ̈́Cа^ 2^(SI,ՖG[}!}oU`zQk/wk:+'`2 pKI16BsZI,RҘunik : n>/t2tqgodF|tv&*[y(8հ Jr=Z "׍@6w\vV,̥XL:tYg:|wRФ|Џ"]4êf%]m O/a bv,X&(U B€8H1qWVyk̫Y}1cz0p%p7]N, tmMkEߛy>$~C.jF'>^zެrIܛ.8,mq&zm7>lHK$l +KG[ќD>Q~QT cc2.Ox: îIpsSo25^vGњ1e!P?q r+lY#(@#]:5b+ OG{#ӷ``wt|R6 X FQv_xA#Mz:S=yvSc?PoqZo I,a=u`~3hF2jlQ͊P `TɏGI2JoxCHN+5&&2-Ԫsƒoc-ᡔ5etu艡h;"!8  %:c+ އFא븼̠FB+X2Q#ɋ :)w9(ilсaˏډe~`M&;,o7|Y52MqO \!@L6*Ӱ3ܷG֪+GTjLýs ڎ[g'Rrt˪wZ'&s$ݻM˛.Щ:\r .ws6M 8k:^d?~uI=%XtCQD55ą|2^<EcIo Bp]{r&Y̥+B@I1Bhb1ZcI .ϲVߝ:tk6ԈS缪oYkHcg2|"F0@J.h!^,PP d3$P{&E9.A:P滑nvXqAewI~.;B.m$L\r;Ta$HVvQ];nkvjwT" e= P\13H7T S(YZG)R]cthK]CP-+lHvc .`cBǫʋ7򴕞:!VʎgD* '#';KȝHNMGzP܉d)!cޥN_u?-|k}Kfv:k)QH_槅Jr`5rix$X uW´lc+rVe⫣bq^zz糙A3& `k* .4!ϡleGz]GF{ܣ>pqSúyu# ͫwǜE:U9.έ<5pϘɾۏgm =Nj,xiAb<;qQQVK 4.\7\" N~9!'{oN/ DV|pHRaVI~VnWg!fCjz%Ն+7@;> ZhB|* ۧVN (ۧ&ƔS" ʞ I 1w,\۲7?8_=V^2xG@#o\.x{F2T/CWQ@j@i4 i-EGTHUqu~o3o3?3ꯋۢ< tB~q%~@}BsA6usLvC/3"%ADݑf_XT 6G}-A83s|BwL,hR;I >f>${@}1F"OɛtA6kR6`Cȸth -9J)MFZ9%qۻc8K zbI|@&dvM)d>(ppZb,Do ܑ tgǿ~`E1),ޘGQNV75Њ+ Ecx4|lļ|=UZuúY1^(N%5'x~kr2̰xP(I)dq ;MJßNP[k_se*Ÿa5u gD0TA 0&}^ٟRaEr$0,gtW!nsHv-I;nbKe7G`ZL zv#Gmv0.tۻm ,XPDdѮHHT~rZ,F8 -(4o/lS5 =V s!f Dm۩E'e{w'D}Ee#_E|s?DuwR  B̫"ݼkz 6 j<i:YpO$L Ĩ099N>FA(6ty>jbt hA:P@iᠵ@̘Q@pX >.wzfdJY8OHK4x*ikid<-NCq4݁wgX33ӳʈ$f?oׅHa z]W=rwdXfwцݪ;SV3Dtpٺ>RmIOOr.lrS7gnm^?6+q{6Kx Gm3r1lI%ehS o8ɂ7| Udn{_}l}m="z)<5FeKS͐>`]`{tf8ud rN_2[[j`ڴ!$0&D?j`ϸ2U al_Lv 'aY-*EL&TU%y&nóbM,]cE_K| >I][ &b@ld>m9kqsFMWRp\0{-A{2,eI5g?:r%*%nkb6?ȟ9թsdЅ\x{QgzMSᱻzsX&2g?7WWe^UHnH!przi`ŨڔIآ2M #Yv3$u%yZRVO1.W Mg5eʎ׍;sL`Dk%ί/,=I,&hĬG9}~LKlĵE8>7w6 w7 a` ZxHFJg^͕[[;|9N 6zŒW|b~JpM411af4N+2n*Z*écRgXJ 2.pGc)Rն+ѥUw>Z: שɗ~ӈCg^THfy BЃ HeqȊIe4inTZdѭQ/G <]wH]2?aqf\1QܮɊ\R/El0_*!ZOCQG.o2A`,p{9NGwdjpdꘜEฯ` w_˱%дX٤{nW 쳈C8x VJz_΢7^trplaE'R^y~=Z'Ibwp=;Heň&Q^1wԎx#9%0w/1_ #Ӗ7GT~:+}uDts0$.~6esLf|Xe-9Ńh{ozM'mm4Wk%L)Tu;߳G;Ohλܳ~FLP.lgyOf%b{9;4}zyAk|GB-G&}40p'-B4WEmAm Y1W <Ϟ pU:P"!O#7tU:YզnXq*Zh޻Հ:M3gΨ<≞/zb\WMoTCC:.͐_yIy#=hvDq7g4dU*|`:CE(@ ҿft/pUHk-x]ӿq<Ʒ]K4G@{xoZ ywא-ָiTj$<+p|RH >ELŗ]_s}ugN)c^SSLȕyi^=cl!n/.~R,dzZjzr+IwC,o3 g[iSMk$ K [FKJs3mH.unQTj\)I5~4>2(5=_ )WQ\nk@b!PN$qJ o]sV^ͲyQ1tx· DNLmj< '5r:ѻ˸@gf$H NEf2dFnAJ]ژ:壾"QJov~k)r0vb J]9.!tf@*)ܩ~@^/vaO5ӠBxzy r?}z[K2H"Isl!γ6IQI.̒#M=؝ְ7ʻzښL90MP)7"~ 3Bwa rӢSffXt4Ety@8~ĝ Jp[1;>"uxxeB22='%kH/WYW%.qD{ohvw`fcd|_gC*)%V!}å#()2os֞%+|WuĻO%h'yU-ʛcit\elʱӺ˧>-5/F;K=jQ{= ]!pAI}Y'h/6ȫ0FwWYo+>()=f*u)ofT!7Z֕H:]iY[Ah*BBJQkTڒ[tbMF MSY+wj-+pnotⱗ%WO;DƀxH'vmX>T2PSm[+"W}T]+xL1D>??dSgG:7YnѓAO v&mU0/K\:Oe{;5K:5$\oȎ[IXzMzęp~dl*exB{RppcsX^E^N&ژ%!ELޟ(;% ׮H\֛.lf"[#Ԑiz%h *j]~bpt_g~x'8P+q; 峬> z*D n D x~%z, uad+HFmC(ݴOI#xf{r,F+#  KMWnk8( z6PyKXͦs߷ IG A_l #ve; v\wX/Ψ㟖KoA5cQL8H&U\u +Kn !-#8;ɰܧ9 SU ɂ CQV|>/qOdzqkS,CeA&<+xAL t8,t {| 7x2 \:#  #kc,2#KG8]BtFxsɌ95M..cbqt2dsp&wm4dSDhDzTUY-g謕s@5sAr=2fqUN[\ܨ"fDʉ*z[p5d\K3OT'+Y{ї4ZN>_kj8 eX< .cgL+g\sn :]W/V;9pN:_,üGQ94ǿ5g W2m3*MxapnZ,U39IȕJt[Td3 1j892ɛdQ @CZ?;b4n Ḿh% zl,mÅdOf.Y(Sm- O{w̏s(eO-%Q #wR|<"뼇36){?o!;:3[W {tɱ8̯,Lifnt4D^ iV[xp*6EiFT-14[cLG.F_'2*4LOE"RkM 4m_;6N4ԲƸ M1epT0>?`L2g1tc&HʆLOF b8`d\6OZА=rxNKpcrp '&c\ >AzQL"5 n56%ھEMڕ,|`-(jZ.ض$ <6k[iސO+($RrM#2qY&") WD /轄;m~n ^YDM1z7TqV[qjuXEI`!G#RxC lqR"$ H>Mzw6 B!6xE|uy:uᝯ̸>!0uÖH,t1 @~qT͂w!xXE=2xuLETsߟ]αEC-ǝLkr"#fuQ5 Qe H<0;xVmEⱯyDae0ˠ] ]ɖ8̇pO-I#S9$]N ռ0V!]$(7GؼJt n0J*D鮲hbLٍցǧ%_gzy<"}{ڥ &b :uEg,xdp[vv{C\! _vw^F}*'G?i,scpoQd)a$ 6+UnGc+g|>vF*DCf2_"yw%-3iRu~FXi8jO9g Q^u ^\L!y1/^Qx GmcP˵(H~jD6ݣ1[@B̈;>4ԭTrvLp_퍎zp|446RuZポ NF'GWl!}ǰTͭ) OB%G>xvңs T|6 йj{/YzuY;C1>ͧ~&Mw(/!ή^x>͚ߣ<_GFSe3қInۉNZ]m ^&fUMO6iR 6^, bC:1FkxCnud$ޭUUgp R'hJd{ofۄN$X6[=90F,)IBKOZ'`bZܘ~(„kDݱ~YCȔz s E8߭SinwFzHIڎn'@?lʆ"Q-3HH-47~bKc=G_dj+f ]ˁCn,)m5) E#ӍrgW] E4W:Xgիs/,rKh I2OE;vW V- sym勳~?FGR{9Y+a;.Ni2쐭%I.*LA)Z/q1E&|::$fT}aW0Jӯh*c@E#hnIkQM[z4~A iNI2͟ϯ/m[IbSTݛA"qHN4A/-)&I!Nhֻ5_Ua)*ƥ%y_)eKaZRNnͿ,'/9EsɩAکBdR)7إjm0z6Q ?zl54r5$!Xl~.+pvəMJG?bv*g%Q(<c=x.p\ij i[ c"~JaDzyߋ.~Y/LG vL*-hq,^N dxGYԂ%8bQ"չr;G?sTw:uP S~F3sam7$VJVg0Ŷ G{M0:JV?=@1xЙ@AS8A*QZ#@QPl<6#sFUM : bҀBF§BEN=/W<"qe\ _e~7651.2?(M5M ZJ$w<߄ M~fd(g6 N,JM xƔϋ3EYN0$0jK 0Y(xE\y#\X7;nm_3Ͽsg@o XQgjm wm75r 'DnC.u9GYsfx+l!(Vu;] ISxƎ <36b.foGF%#*z:eN S #o41R=KM fMuܜsγ0k >8Ȃ j8Wgɷ/`bکh$ȉo(}@1Oo)vY8IBW6NΗ75g2Pw^_lB8iXPQs|d!I.HAb3J!DM0ԇ|\"ZhyVM?hW61@=.pe!JF);k[{`y{22R@)`<32$ܻ aٸa<:U l\/>ռEid3{ӕu~^ې\iG, LJS0rjxmJ䒹~S0O4V\s'{ԂbMUvVo\ۖlߚz*e"ځd4NWP-DBq fe,jkg\ĸ윞l0ba,kicxZ䌀733tmrʣf}`3L 3/shKNGO;Jk"_da8HMXNQ_iCa7iɚH֭cw ނ;Q4+.n&P+P.RT^jiWC7^"]eUj/Vtz *:Hhy^i$58)gr(@P 짓pp4:>m=l+"9`&Kx#Bm8S IвiͲbP@ xlviώVkhe f-$X~h!7\4C2O^NɭMV7^$/$~ʸ$$O0(-;H/9i~N+ .E2រLMپͦ|4%"OD3-2ÎJ2oIgR^͋k,^ ƮkZ~6*߸ H 3jDBf|9~@iArdAoE@.plrFy;7Q@66:&^_wQO)7h f^0uTpoo!nvS{f@SbS[,%}>J8;61ME3S1콊[[~淗Ɣ$ևb [SU|L5d9#Z 6C0mȠmv@,Net"HM;B9™QJ|HaQC/\[WME$ϱ-ד|Y翭':)|,k5 '2Ǫi65MZzz<]Nȡ.|Eqw-wv 4U9ɴeF!J~(W>B_2+'YD3iό+#=_e;NA>ZS I>1q{{i6{nSē?ɄqA+  3"46~3bTD v% !Ŷo*=%:2 0 DJ'=z侦hj1ŭjFcl҃߫0 "L+ҋ@Sr?Ł+6a7S{Fc+g]vcwK!-}$dͿSó_#̅jԆ&KW]e]↠:9Ca(r8;ڰ.p fgF^Y~*[] }Ont./qf$mQ%^7T](^`xbmIQ6CC|h ydssMBC#_~*H"}uJ-pqѢ*KryKl5.0VB?؊B 5&}Г#t3`!,{^_:B}.;n~0x*ZPl0з1m5.ѕ 8 5Smv ޢ7%xgAT?IE38&n%$#'_jfv5oF'xQ46"mҠWZjQ0ŘL}KVaE,?.]\_ubKz_gA Y#2Mݡ]gE S,OlpT2;hxT&saW핬ld.mqfh3 $fqs9c?AOhb PI<霗 ygpd>8vϷ_w,F^7֡k2MJ dP+ Lm_GoG'Cho`ɶƇdga(7 Ċ"((`Aʾ y!|PZԔQ4!R/qZlH-YTC6KM4/OGP!=9 rtGf;8~CmޜܗiQ v*l5r0p-ؑTD[#ŧ/j5w q='KF2ȼ$ß[=ܝ@%w-i {q"{|cg_7B(KOrxU>rlQ YOmy#m/5r7$,3 6sOh|ՆVo(I9qdE>5z$l*]SA,2sr2*(l^00,Crv T xDxT#HR(T.[& <_e2?{ߏs1.w[x<Y(-?"@BAFyk?>tZZn#2V#CعְEAOE=Ht`G$>8ƈVb4?A蠘:Sɫ'䠚.}%3+Yn;(|%X,!詻 l*7"}%4kG$ᕙi)Q=X.۴pH)>^0t'ѣ?4T'MTsꪼ{*h,$4`ZV2 =ky}DOzXe{QBXy:SZTB'̄F I9a=EV'[pMz}P(TK^+uߴzD_wkۚnO6쬼ky4NQ~2pzPɸPF(_}\wɺkk0[Z - 20Icg n~\ .%-荤3%0n~K)XMJhG!_u|3 tJ:*vi|;ud8ʹV >:mtVDyXuYI ~#?*㰾mg}am+0ڝwuW./7R|3'C 1̌G"`SKꄥ3f)|pRPt< ٥ҖT0ݏ,L=0cFk^L6|7CS6tL[J.AɄ`ǜoo7zQgێH)@ҭ5t;:(ǪR/&Rl lݝ,>~Qx}o}0?6fg^|+p;7G$eQb\ϐ}D`R.wjo3A2= [5y{bi66b5We"Trmټ4{ I}'UjQd`cnu`S)T- QCzd'8?!g 3O{iyd_4"Y񽛎0뭰3>-|l9'TXrN,RINh,~ܸjCnf& ҮhMbO91NFb^]i7L]`Xfq M ag2*NRPG[tDjjSMjq3{'Dt=x^^=@ׯH_qb#a\&(KK>9 n[%^-ŔUy*^W\YD]ׂkB'>-$ږRtB]~(W+ZPf L8k23JUj mg^ $'yU <ǭnP.>vplݾWvyy$v|sK5Sn]<>eۊWg"CwV^^/nN~g]@Dʯz U ҋ0p h87Mvsd8B?\ӟ\eg]QS-E벼rh;E$VgDihB؝b;$_ e5A٩*8/ aKw)"$aN >iK$E1]nMX0NpXx%^t>?gɢM!f 7LGKg%ϳSu;UHyVkD1mNfNC6W)BQMQk|Q[,>#-̬nE%ŤO7Gy &#lam01'ehzYGF"J * 8$ xxLĜ;K&cv `U`=~Eb ^(07^"8tKcʕRdvoKd@pݱ=%RbcEX<OohB+xuTP.H&&U@ȩ` ~S =iK}$˴qiFmBq Y'Eus-DE~w>~w>~w>z7B1~M&YVg' Q~"NSUޚK8(-98ʊtm6PNmgƾc0:)[O7NV==UlʤoيY?>3o'wcƍZو6RYK}-yXS*Z[~XGIvj[OzCz |UgOSEߘ~+Wu|rR~Gm}E| |Zظ2k.WpCoF@W z6]BN0+nL eEr!69RGAn^[Z~UmI8qdI2ořD|~&^X\:H:h 9fs[͋ Zƀ>ۇ l8kEb\d{6)(>qm(9ޖcnBǀnH"Gۇ/G7n7ig{1|MpgGi"p` %kX'9ND4-;'Ã}V=: RM+jZ}~=h! /~W׻/~9%-m7YXrGHL'7ly2 L϶p n4_8~f=oidl%ۯ{㗘C nKfH=9SeqޡpZU}=y荋qς.E=Nt3Md^-!q=IT.D}\3UD#L,vp8Q@J;C9 f룽_k\'9TџUgskh3&+Hu$' !B>{vR)\ <,N, ԝ$^7b<rpyα_״A[pwpgo8inl;sرi~/ L^2=%t'ºHp/ MpQ.}}}bћ+5NzR_N!!jXBtsB(Pԛl|yS|8xv 4qk ZjQ{㾰}a9`GSp\ T!*@*:^%|;Uugt2 -JmʙnR|k @rYlMk^Ow=ŷ4:"C_;B,}77KCcTL <=l^ f[8H˜rR|gd g*t5E|;>p Ψ$ύT]5! pR9TL-]%uuN=oh Y^@*"Y0mf,$ӡ}ɢZ Vذ).WzQlSݖdrVۖ|ѱmKIڵ2 OLM촔]q?E3D1i^FhfW,XM!Ԅ'piE5߁zuZ~IJ(&ۧD00_bHl}{ lL=%d9oByo3I2Bp. qd,Z!}:}S'mwRVj& OC0){IaءeQqL`j%.7-3vJ3xFdP0ctwh{ofБhMF:vu%&.1t~9%Ӳ9 :|w_N  FW{`j504k0N,ҮsSux{|{dGft˲^6{D.!~5f__`ܷcMHS t:ëT~$8ej8ZWuO9=b {>`[pDN{IZ ișjG'~2wW薎O?_|W+&̮% 5acDgz 3q p_ ;֪2&H `g?gl^Iٟ N =l hQ*O6'izsec_ozQ~;Mi/` {_6mo ͜Ԅ-IӃw%)(Wux93< Aˏr01e%^)3/scuӖ^~{!{pJ:ϺZ;lg<)A@R|6<98S(4&H31/;,7j a1L%`琌m}oS4Fk B8Vt-0~1/g4|>7L).$$j?]AP<^mIFy>;)_BͣP/`RO!W!,\ .*Fh!D1Xk]oЃ]{6x1gOsWWnJR[`fМwr}ݮ':Z>/[5𨼁\h'/_X ČZ8X$IpϠy|/1?ZOGb ƀNn=(Boufk!p9DO)-mY:hjwonAVY>!t~zh9:?̏Nؒ"1; 64Kcx;3kpz;+o#[!55|.,`ՑPtD4tӴ`2=X!:TAp'5e*bn֐ E|XpOg;TTܰ"1P-L?aoĦ2n#8y,F*'2/t:Y(|[SOT0O9 0/t@$)FLʳ3|) Kv=K2yUl%/ޭ#OZ0uK?ǧ`xy"F!q,nݠF^ ߷.P"ȋeo112RNQ'6=H5]?DS"[)J%o./..,_/(.G6!_5oZ-kVnJ1^i?wGOgk-"Y.Ie:,H%M<vM q9pTAˌٞwH٪@538[SWvVSH150P.`&p Px'Bjt=C +I 4a~!jX7`qk_a@kcYuV;a>ߣ#-ُr,(S:K5AP^4[^_\⏬m"<^RwA/fvT'nh%<7J/Pln]DU VgXA ?Tj[C]vT!s~D`k9NY?wMuBվDF@{,Q"w)5"sٝ` W?<1 zO]kbX} A^&&cF|5E9k[ )}#ȗO6`oH98/v#vqcJ 'kґ f~a9RTX]!vQ^;-_ xXpR1/f R{Mc=Kw9@)F["c^q&4B׀[#NT< DQeEfIPs}~Ed,<˓8?@% '(oJLԵ`ڔ%g{$fZ^BfG*zF"(n?+eMY,ۺ$#3Wۥ(g8f]")[1)F eN]tTK >*ltm3\f.sVlqxA7kCri%\o#%;ULa "֭`lhQCh7%7hu}/@Ɖx&=fp}S. Ee_8Je K" ݶ8 whYCTА(cMc3rebqkFh5/ 43}"#OJ MB"cenT94enۈ$#DS &*߾sK,5&qSI}5Bч #JLY_U?R |zp+8w;k;Wϭ|CIW"PqcÒ8iϖ n)*o\Uo߿묥%~T~UtMmZP|1ybHHvyqAN4 $2>nYr{#(iJl˨>ESCʜSýXAѲu=˰Cւr(-P|+𡨮{΋|2Fq;4v$UK d1jP+eT>:XdlIM'ng3ԝ }Q2cj3m ,4~SnGXlu2ۮ;n4+ԧ6$ꍵbedD` bkz䃪# *go Ѫ0"aL oR/ԊZ]|D Ń()p+R1;(YLO+L#'R{/F7yzrÏ)y߮/߮k +vl~;[#@.HיrO+j i9)~cS_`,5/QqD(rRNfn6Dq~scm>]r G[`K Oat #M̝uap旆%rrхltu\7h/`3U.&CW^2h]tZ_O̽{/OTrZ,}]r'%\8&#a30@GZ;tr~/ =O;˨|4|S-tSn|H׎\Z?Ԝ1wsvxy1]P "DЊ *%xS1eЍÙ ĖE5LqVǐgmnؕ;kΝ,qE*ٔcQHh9mUc8UtnƣxN{vXdكL_x=4?jc7} I #TILMQSWxKt=[Xsn]tDfQ9q,ŮKhCCyʷ&=ѐϐ`!_BOj/I2#lƋX"1^41JߋD76U9]]tS)xkA U}wp-SD= ùZS[Menx8H&m]DcZщBW7/1c>݂.H䞨k6 :^4*Vjяfϓ>T\pmq<9Yt Ť>Mՙ0+'cBk=I*'/'޴K;.bOˋ UXNRpi0!j}?AOykP>hG,Y|קW7mXIlM5}u|-/aRAz`ώ,3w  Ȏ)3% =CFISHQyVDokpɘ}NL+C]ݖ_uym# $D}hxr4Z?wiZb!f!q9>Ls+/h'I/^z#C09^|MJ&m@y]uܓS}٢bպڷK/8.LmОq'WĥglJ}H0|? O>iש-]gt\B,>4-w-A9}6azƺ!F ;WƇ7- { v.8ٱg܅n#Nɵ|xa-ă&^Qhe%"|0(UcfnPc-].`%\е`[|]4J<љCD7;mE:Cȳ^`x&<I'7mݳ(1-ν1u1K̿uBff,͸lS;&ܫ8)q2XO@ouV۩1h v/PՕA}lV@Y;na,]lि72wRO|m#e;D8RaEzHztuŗ32۫N'3YIIdO8&JPc@)ewF2bw,i8E>sA-|oCcͺ c&'?3AuzOGNv$utuG{tQ'hႻ Tȩ?MNF̵4;R{ P禲 GG:T?IMZ,u6LO$<6@Py3Izdi)7$J!z's QQmXAG:"lMUiwf%X@ ^ c%%C2pյYB5yK5)k 8-w= :#]S5!u 9-ص>@`y&xÕ&ܔ5ed~ROR`0cP\ uuN(s]_W5ż膪N(J™#`> ERC>ᘄ` 6=IFfs؅ȌYߦEwx:'mQҞAD$'M=ͩg@d|c0I(_''NY;ιy&7:Ű,dS Vdc "" R^X'LqHpvN숺0тr뱼)\{#z~3ޘGġņzͰ0.U1uׄֆ OVj *~*Ǿ6ɦKwܗ ODk̽Q< *QF4\/ p23]1:)265+(?8OLWeT"Nc/pk狶 /w3 _`5%fVT{c~qV*l<:*j^w*@( DkQqz@ 'rN;a4:8z>FXp$RUPc:]2T6gw9!!`Ҙ3 NtoBTdC4ix(6==a@)J,AW4ˆn V7'bRy@\{Lml@ҝC\ysh[x`ϞA9XLQ=:I9lj8d`'΃CL85#~h(9-@>=꬜${sx$0588%E竭bp[_Wcnz``!ql=q؝dḕyf ņczcHNX,%&lNM2TgV*U/`XZ%_{r.d5V{Is#Q&xkxND ǯh @ƅ*paUK5 :qӭ]v 66~7T/$o頁$,NfAp2aqEVz=c %SI:eT]@:]A.YWo0V\m-NIkG}Џ݌F~uB]ֳ\tՅ}|I^\Z fI߫'X42N`/'z~';uI_5gǧGY7}tO=jBk(M>l.QpK>/b~tٛh*1tV Ji䊓R\iI1϶lkd=$4ż[e>hRq,L&6_gCL=H\"# 3Dj!sqSw7b!=32b6qԽ$gˏq8 s\BLMLؿb(!^}Z](twJxx?ȏ/ڽ?tzRKHfu6C<8Ku{y7ۿ*hpٿ[6p|Mx3q,3TNc*Aʜ ?8*T76o; [s;X_0b꿝`߯  6*u.zt:Oj?"Y8P\rn7a#cRW0V,ɐ:iق.}[B^͢*<[qxDO6uա_,?+3NEFcB est' urH!/FTX(CJtʫ\)F-vvKpo Rs  G^]ەLGHa[2I[LLiB[6/Zl>Κ(hb Fd#6!َKT!|C,(\q5*m{7_7k^)|Mdө^H\-91n2]/-婮PXS\z.V^ݮ"|h)@$\hӔP7%(dX`H&fE+L,PLBa~CrhU0cAwZ&oYo y%z]RHQ7Ax<>\@]\VdU*n7Ts/tBaUEJSEDJ0WMZqv{:mqѤͲ"&.mY۶Ç|V&&{ݢUѨc$HeO%\iךv0 Ѱ/ȏ c Y3A<ܫ!-wwZtgE7iw"<ˈdp[CVMy.oiE-94ks3aHpQbmWB%ΛԨaV~Swz܀E=6&WMZ[f 8k&C!kt4.9rV5QUON{icpZj\/Oʶߥ嫎ds$Xw.X6uېLtB6r.w7VJ1T4X7zSO&l,_dju'@F?+6xnAfoeMpg4 В )g@VUJ]idcE;@ٟ|8 rBM^Ym| _:4  *@81LJc*ڙtA $x^V9@v;<U4eUpjR||xեn@EIG ;GF6>fi@|ׁaM@5!0w!FءOt&dCm8bEQHu{-j -E/4P".|/B%v=Xўۛ޻ .)͂: %]xn^;J赺!XXU 3mGeV5- +[4)P<_s|VS 0껦vk| -a@0rڜ-F$&L+2DǬudR QuošM!R$O1hdm;\l03G 1v{J@jkv U6MRѺ>MnsFuePZDMAFkbu:N:U)5JǺ'G;>΍rg0M y+L*J]3;N)WŨD&Jc9lg'HAUE 4rI{o6=2ȐSG N/ɽk2&qPE ϵȦh97ˍp|IxFQeHzޯOM7s{WlWSj+A&M#[nY Mf+j8$כ^XVHZ,↏kD43h4xM݊;;i鈐ovFà.$=;;VUk';R-rKIϘvWؗj9uzcںt@ꮑY)#Nn#$SDk;~~]""Lb"5y|tUB҇O^7Qɰ cVVa#ǔzk}axl*6jӟEb8) 3p 7ޓlAq 1ՇeTw9fr~;Me9/Ga5gXϡ0p1}#ȩFoYگx?ay`س]M@evR#꓇Dq7gcc*G/|]QTVF{YƪaPCELwpXc}\%))[-F~:8?i8qL-ϖx[ԩsH|I6; GP2ȾBe5M 3JlaN? /_ۉ6.$dIK[6CS-|)k2k;d'!P<8Ӳ kiNu32= D938IMq.!u6C">2vSZ8" `wR-鰵}Ze)=yзspty?d~mjVzu 4_CzAjVQ+ C6R:?'F0ヽC?=?wA;?S&#7s o5p GCKoG;?vG/0}F?&thupt2dOkV]hَ|_@s޼H):wX;)(C|g^st\` ڳ\/3eg>O6pQܶxϒpyuV׷cy]ƹu/|([L$cA{9`8:>y?ǡ*ў.w(;p 2X5)ǃ%bSg_"9~"+,'IcTMXz<4<(*R.{˲{tğ3 i&-ϰPv[`Vet^o *x:@_ ʽQ4|VOhS{?v[`͈{tzݘdluO%ڒP 콇{*+uVVEΖ]{3݊+&X9!9eӣĸ23Eu oڵhkP,WbO󾆟1- es:{JgSQ[bfAS:&]wOiRqzCdJhz ⲘrJr\]:/AjeԳr9mpxH3!^qhۣ{ BNVl-GΣOVvE`ΠDtyseLâ 5RTC&a=,f뱽rқOYvTH!SQ2S<+GV7.ЯU8>UgA\,JNp\*Y oZJNH!oVʛqȤb6kQ)Dx`l:~T P%X.P$ًS[cD3ZzT?|$N[T&ېmw+SiJy;99懶cNOᤸM6C2;Ñ!8! !&lASР!>3kUHKUoA,?[_PwyTΕ`dK`=}>><~>d @m\/`ݦ"pFޅȿT=7"Oh]#A)hr]{C iڨ >{RV٬TGv?@Y]LpUNuO^F'ᰛt (2"!@IҵJ(mJmЛ@AV,ijϪEګJ wf^r-L7cuM@YIf3d(# j)b)(()smH5nw3n'/۶}kA}3DzK 4B WVٺ\=Hk~r%JMd9%!67쵪a |/) ߗ`^D5\1['7XڞXb,Q7+UcAK.#P͑u9@v0vVz.%`%8ɾ#]a{lE%sy"2P :S|4(τ# cnED7ґڧf<YcC=NGx4FPFL2arL}lNECǴTG*T՘I>!)(NHZz,7xYm=z;-hѐbBHN:$`ccS@rDFd2gZ\D~~Yj nw)` ;f޺٣ BWE-qC^H5ƨ]jO!Z!jײNmvbsN=hj<[0u$z'Exds1?6n΄VryXω܁NdhXBEa2N w%9^ ?L:['~/֖'EkUć˓Kt2.  T}$$ nY4{DSM":|x=7o @7~\$ӃzU`)%cQ1}SJ~/dITo׍ٺ)ZBw|a&3>Qx(]@/A@c{{}؜Ius;ˁPHvfcWpMҒYIZSU!ټ暇@+_/J'kxq#' _ulH*>kZ/==-%KDShEHxDe3}j,6! &pXLqLi\fcmOB'dmMUfbEcDlim =;2QTRaݎx$"ngg70:yRv&>qAu[~'oA -^hMBi{M ~p"4KEK[6f ?n휚%9oRmrwKv] ".2.C!walCfh^Hf۞_NZK*8Bhg|?A 2${4bљKo(\\sZCT@q}zk18Wn:{ Y[ѹ-ʥRA-5WobgylSѾT\usZPP]>v͗ƥ ltW! (5<-YVH @ټeIm1E(wR[V۵PHsڤmL[+E>#TF ^mQ=N̶(f0WMrR"pX^àe" SȓQW,??`"\a KuIĄ8}xڬrzfOwEo=e{(ME(*ʡ.fV~"5"0 *_NATx)E ءj ^v %\_\R-> ?EM~cK(6`fPt͝_QUS%Ŵ5H-+|S{ l gХO{A Ab2)!M}EGpښ+p֯0#lB{ ~MFnމJ&n<]|4bxL\ (QDcbEDX! 2"ބ;J9-7H ֙HeR`BkhSVsQOF/L4yKhFP^ iP )?ˋ&oUsN8h(=P"Ab486MtƁ*z^ٛ{R(>6trR,pϧfp*mx@b*WW?0gePM5\DŽNӶyvlk {q%uzٞCQ dvi,-'Qib^nb3cƂ4?y{ƀ4gQKevѯK=3YT繺L;q=P ,FOQM uVIU9D}DmOf[,aʋqN`V B wRCaA5Pi\Q?Ϳw01bǧ4"OSvv{0c*5ojE/O z¹cH=|E&Jtou2[~,ίxוb^hˆdH9^B>){=cxڏ#!L= UxWL#6*[ϗjJi&>oPZM|'jŤCu˯P-tE5oVN?~#H"`@nE}/R`vWAZIJ // Copyright 2017 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.exportPath('print_preview_new'); /** * @typedef {{ * version: string, * recentDestinations: (!Array | * undefined), * dpi: ({horizontal_dpi: number, * vertical_dpi: number, * is_default: (boolean | undefined)} | undefined), * mediaSize: ({height_microns: number, * width_microns: number, * custom_display_name: (string | undefined), * is_default: (boolean | undefined)} | undefined), * marginsType: (print_preview_new.MarginsTypeValue | undefined), * customMargins: ({marginTop: number, * marginBottom: number, * marginLeft: number, * marginRight: number} | undefined), * isColorEnabled: (boolean | undefined), * isDuplexEnabled: (boolean | undefined), * isHeaderFooterEnabled: (boolean | undefined), * isLandscapeEnabled: (boolean | undefined), * isCollateEnabled: (boolean | undefined), * isFitToPageEnabled: (boolean | undefined), * isCssBackgroundEnabled: (boolean | undefined), * scaling: (string | undefined), * vendor_options: (Object | undefined) * }} */ print_preview_new.SerializedSettings; Polymer({ is: 'print-preview-app', behaviors: [SettingsBehavior], properties: { /** * Object containing current settings of Print Preview, for use by Polymer * controls. * @type {!Object} */ settings: { type: Object, notify: true, }, /** @private {print_preview.DocumentInfo} */ documentInfo_: { type: Object, notify: true, }, /** @private {print_preview.Destination} */ destination_: { type: Object, notify: true, }, /** @private {!print_preview_new.State} */ state_: { type: Object, notify: true, value: { previewLoading: false, previewFailed: false, cloudPrintError: '', privetExtensionError: '', invalidSettings: false, }, }, }, observers: [ 'updateRecentDestinations_(destination_, destination_.capabilities)', ], /** * @private {number} Number of recent destinations to save. * @const */ NUM_DESTINATIONS_: 3, /** @private {?print_preview.NativeLayer} */ nativeLayer_: null, /** @private {?print_preview.UserInfo} */ userInfo_: null, /** @private {?WebUIListenerTracker} */ listenerTracker_: null, /** @private {?print_preview.DestinationStore} */ destinationStore_: null, /** @private {!EventTracker} */ tracker_: new EventTracker(), /** @type {!print_preview.MeasurementSystem} */ measurementSystem_: new print_preview.MeasurementSystem( ',', '.', print_preview.MeasurementSystemUnitType.IMPERIAL), /** @private {!Array} */ recentDestinations_: [], /** @override */ attached: function() { this.nativeLayer_ = print_preview.NativeLayer.getInstance(), this.documentInfo_ = new print_preview.DocumentInfo(); this.userInfo_ = new print_preview.UserInfo(); this.listenerTracker_ = new WebUIListenerTracker(); this.destinationStore_ = new print_preview.DestinationStore( this.userInfo_, this.listenerTracker_); this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType.DESTINATION_SELECT, this.onDestinationSelect_.bind(this)); this.tracker_.add( this.destinationStore_, print_preview.DestinationStore.EventType .SELECTED_DESTINATION_CAPABILITIES_READY, this.onDestinationUpdated_.bind(this)); this.nativeLayer_.getInitialSettings().then( this.onInitialSettingsSet_.bind(this)); }, /** @override */ detached: function() { this.listenerTracker_.removeAll(); this.tracker_.removeAll(); }, /** * @param {!print_preview.NativeInitialSettings} settings * @private */ onInitialSettingsSet_: function(settings) { this.documentInfo_.init( settings.previewModifiable, settings.documentTitle, settings.documentHasSelection); // Temporary setting, will be replaced when page count is known from // the page-count-ready webUI event. this.documentInfo_.updatePageCount(5); this.notifyPath('documentInfo_.isModifiable'); // Before triggering the final notification for settings availability, // set initialized = true. this.notifyPath('documentInfo_.hasSelection'); this.notifyPath('documentInfo_.title'); this.notifyPath('documentInfo_.pageCount'); this.updateFromStickySettings_(settings.serializedAppStateStr); this.measurementSystem_.setSystem( settings.thousandsDelimeter, settings.decimalDelimeter, settings.unitType); this.setSetting('selectionOnly', settings.shouldPrintSelectionOnly); this.destinationStore_.init( settings.isInAppKioskMode, settings.printerName, settings.serializedDefaultDestinationSelectionRulesStr, this.recentDestinations_); }, /** @private */ onDestinationSelect_: function() { this.destination_ = this.destinationStore_.selectedDestination; }, /** @private */ onDestinationUpdated_: function() { this.set( 'destination_.capabilities', this.destinationStore_.selectedDestination.capabilities); }, /** @private */ updateRecentDestinations_: function() { if (!this.destination_) return; // Determine if this destination is already in the recent destinations, // and where in the array it is located. const newDestination = print_preview.makeRecentDestination(assert(this.destination_)); let indexFound = this.recentDestinations_.findIndex(function(recent) { return ( newDestination.id == recent.id && newDestination.origin == recent.origin); }); // No change if (indexFound == 0 && this.recentDestinations_[0].capabilities == newDestination.capabilities) { return; } // Shift the array so that the nth most recent destination is located at // index n. if (indexFound == -1 && this.recentDestinations_.length == this.NUM_DESTINATIONS_) { indexFound = this.NUM_DESTINATIONS_ - 1; } if (indexFound != -1) this.recentDestinations_.splice(indexFound, 1); // Add the most recent destination this.recentDestinations_.splice(0, 0, newDestination); }, /** * @param {?string} savedSettingsStr The sticky settings from native layer * @private */ updateFromStickySettings_(savedSettingsStr) { if (!savedSettingsStr) return; let savedSettings; try { savedSettings = /** @type {print_preview_new.SerializedSettings} */ ( JSON.parse(savedSettingsStr)); } catch (e) { console.error('Unable to parse state ' + e); return; // use default values rather than updating. } this.recentDestinations_ = savedSettings.recentDestinations || []; if (!Array.isArray(this.recentDestinations_)) this.recentDestinations_ = [this.recentDestinations_]; const updateIfDefined = (key1, key2) => { if (savedSettings[key2] != undefined) this.setSetting(key1, savedSettings[key2]); }; [['dpi', 'dpi'], ['mediaSize', 'mediaSize'], ['margins', 'marginsType'], ['color', 'isColorEnabled'], ['headerFooter', 'isHeaderFooterEnabled'], ['layout', 'isLandscapeEnabled'], ['collate', 'isCollateEnabled'], ['scaling', 'scaling'], ['fitToPage', 'isFitToPageEnabled'], ['cssBackground', 'isCssBackgroundEnabled'], ].forEach(keys => updateIfDefined(keys[0], keys[1])); }, }); // Copyright 2017 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.exportPath('print_preview_new'); /** * Must be kept in sync with the C++ MarginType enum in * printing/print_job_constants.h. * @enum {number} */ print_preview_new.MarginsTypeValue = { DEFAULT: 0, NO_MARGINS: 1, MINIMUM: 2, CUSTOM: 3 }; Polymer({ is: 'print-preview-model', properties: { /** * Object containing current settings of Print Preview, for use by Polymer * controls. * @type {{ * pages: !print_preview_new.Setting, * copies: !print_preview_new.Setting, * collate: !print_preview_new.Setting, * layout: !print_preview_new.Setting, * color: !print_preview_new.Setting, * mediaSize: !print_preview_new.Setting, * margins: !print_preview_new.Setting, * dpi: !print_preview_new.Setting, * fitToPage: !print_preview_new.Setting, * scaling: !print_preview_new.Setting, * duplex: !print_preview_new.Setting, * cssBackground: !print_preview_new.Setting, * selectionOnly: !print_preview_new.Setting, * headerFooter: !print_preview_new.Setting, * rasterize: !print_preview_new.Setting, * vendorItems: !print_preview_new.Setting, * otherOptions: !print_preview_new.Setting, * }} */ settings: { type: Object, notify: true, value: { pages: { value: [1], valid: true, available: true, updatesPreview: true, }, copies: { value: '1', valid: true, available: true, updatesPreview: false, }, collate: { value: true, valid: true, available: true, updatesPreview: false, }, layout: { value: false, /* portrait */ valid: true, available: true, updatesPreview: true, }, color: { value: true, /* color */ valid: true, available: true, updatesPreview: true, }, mediaSize: { value: { width_microns: 215900, height_microns: 279400, }, valid: true, available: true, updatesPreview: true, }, margins: { value: 0, valid: true, available: true, updatesPreview: true, }, dpi: { value: {}, valid: true, available: true, updatesPreview: false, }, fitToPage: { value: false, valid: true, available: true, updatesPreview: true, }, scaling: { value: '100', valid: true, available: true, updatesPreview: true, }, duplex: { value: true, valid: true, available: true, updatesPreview: false, }, cssBackground: { value: false, valid: true, available: true, updatesPreview: true, }, selectionOnly: { value: false, valid: true, available: true, updatesPreview: true, }, headerFooter: { value: true, valid: true, available: true, updatesPreview: true, }, rasterize: { value: false, valid: true, available: true, updatesPreview: false, }, vendorItems: { value: {}, valid: true, available: true, updatesPreview: false, }, // This does not represent a real setting value, and is used only to // expose the availability of the other options settings section. otherOptions: { value: null, valid: true, available: true, updatesPreview: false, }, }, }, /** @type {print_preview.Destination} */ destination: { type: Object, notify: true, }, /** @type {print_preview.DocumentInfo} */ documentInfo: { type: Object, notify: true, }, }, observers: ['updateSettings_(' + 'destination.id, destination.capabilities, ' + 'documentInfo.isModifiable, documentInfo.hasCssMediaStyles,' + 'documentInfo.hasSelection)'], /** * Updates the availability of the settings sections and values of dpi and * media size settings. * @private */ updateSettings_: function() { const caps = (!!this.destination && !!this.destination.capabilities) ? this.destination.capabilities.printer : null; this.updateSettingsAvailability_(caps); this.updateSettingsValues_(caps); }, /** * @param {?print_preview.CddCapabilities} caps The printer capabilities. * @private */ updateSettingsAvailability_: function(caps) { const isSaveToPdf = this.destination.id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF; const knownSizeToSaveAsPdf = isSaveToPdf && (!this.documentInfo.isModifiable || this.documentInfo.hasCssMediaStyles); this.set('settings.copies.available', !!caps && !!(caps.copies)); this.set('settings.collate.available', !!caps && !!(caps.collate)); this.set('settings.layout.available', this.isLayoutAvailable_(caps)); this.set('settings.color.available', this.destination.hasColorCapability); this.set('settings.margins.available', this.documentInfo.isModifiable); this.set( 'settings.mediaSize.available', !!caps && !!caps.media_size && !knownSizeToSaveAsPdf); this.set( 'settings.dpi.available', !!caps && !!caps.dpi && !!caps.dpi.option && caps.dpi.option.length > 1); this.set( 'settings.fitToPage.available', !this.documentInfo.isModifiable && !isSaveToPdf); this.set('settings.scaling.available', !knownSizeToSaveAsPdf); this.set('settings.duplex.available', !!caps && !!caps.duplex); this.set( 'settings.cssBackground.available', this.documentInfo.isModifiable); this.set( 'settings.selectionOnly.available', this.documentInfo.isModifiable && this.documentInfo.hasSelection); this.set('settings.headerFooter.available', this.documentInfo.isModifiable); this.set( 'settings.rasterize.available', !this.documentInfo.isModifiable && !cr.isWindows && !cr.isMac); this.set( 'settings.otherOptions.available', this.settings.duplex.available || this.settings.cssBackground.available || this.settings.selectionOnly.available || this.settings.headerFooter.available || this.settings.rasterize.available); }, /** * @param {?print_preview.CddCapabilities} caps The printer capabilities. * @private */ isLayoutAvailable_: function(caps) { if (!caps || !caps.page_orientation || !caps.page_orientation.option || !this.documentInfo.isModifiable || this.documentInfo.hasCssMediaStyles) { return false; } let hasAutoOrPortraitOption = false; let hasLandscapeOption = false; caps.page_orientation.option.forEach(option => { hasAutoOrPortraitOption = hasAutoOrPortraitOption || option.type == 'AUTO' || option.type == 'PORTRAIT'; hasLandscapeOption = hasLandscapeOption || option.type == 'LANDSCAPE'; }); return hasLandscapeOption && hasAutoOrPortraitOption; }, /** * @param {?print_preview.CddCapabilities} caps The printer capabilities. * @private */ updateSettingsValues_: function(caps) { if (this.settings.mediaSize.available) { for (const option of caps.media_size.option) { if (option.is_default) { this.set('settings.mediaSize.value', option); break; } } } if (this.settings.dpi.available) { for (const option of caps.dpi.option) { if (option.is_default) { this.set('settings.dpi.value', option); break; } } } else if ( caps && caps.dpi && caps.dpi.option && caps.dpi.option.length > 0) { this.set('settings.dpi.value', caps.dpi.option[0]); } } }); // 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.exportPath('print_preview'); /** * @typedef {{selectSaveAsPdfDestination: boolean, * layoutSettings.portrait: boolean, * pageRange: string, * headersAndFooters: boolean, * backgroundColorsAndImages: boolean, * margins: number}} * @see chrome/browser/printing/print_preview_pdf_generated_browsertest.cc */ print_preview.PreviewSettings; /** * @typedef {{ * deviceName: string, * printerName: string, * printerDescription: (string | undefined), * cupsEnterprisePrinter: (boolean | undefined), * printerOptions: (Object | undefined), * }} */ print_preview.LocalDestinationInfo; /** * @typedef {{ * isInKioskAutoPrintMode: boolean, * isInAppKioskMode: boolean, * thousandsDelimeter: string, * decimalDelimeter: string, * unitType: !print_preview.MeasurementSystemUnitType, * previewModifiable: boolean, * documentTitle: string, * documentHasSelection: boolean, * shouldPrintSelectionOnly: boolean, * printerName: string, * serializedAppStateStr: ?string, * serializedDefaultDestinationSelectionRulesStr: ?string, * }} * @see corresponding field name definitions in print_preview_handler.cc */ print_preview.NativeInitialSettings; /** * @typedef {{ * serviceName: string, * name: string, * hasLocalPrinting: boolean, * isUnregistered: boolean, * cloudID: string, * }} * @see PrintPreviewHandler::FillPrinterDescription in print_preview_handler.cc */ print_preview.PrivetPrinterDescription; /** * @typedef {{ * printer:(print_preview.PrivetPrinterDescription | * print_preview.LocalDestinationInfo | * undefined), * capabilities: !print_preview.Cdd, * }} */ print_preview.CapabilitiesResponse; /** * @typedef {{ * printerId: string, * success: boolean, * capabilities: Object, * }} */ print_preview.PrinterSetupResponse; /** * @typedef {{ * extensionId: string, * extensionName: string, * id: string, * name: string, * description: (string|undefined), * }} */ print_preview.ProvisionalDestinationInfo; /** * Printer types for capabilities and printer list requests. * Should match PrinterType in print_preview_handler.h * @enum {number} */ print_preview.PrinterType = { PRIVET_PRINTER: 0, EXTENSION_PRINTER: 1, PDF_PRINTER: 2, LOCAL_PRINTER: 3, }; cr.define('print_preview', function() { 'use strict'; /** * An interface to the native Chromium printing system layer. */ class NativeLayer { /** * Creates a new NativeLayer if the current instance is not set. * @return {!print_preview.NativeLayer} The singleton instance. */ static getInstance() { if (currentInstance == null) currentInstance = new NativeLayer(); return assert(currentInstance); } /** * @param {!print_preview.NativeLayer} instance The NativeLayer instance * to set for print preview construction. */ static setInstance(instance) { currentInstance = instance; } /** * Requests access token for cloud print requests. * @param {string} authType type of access token. * @return {!Promise} */ getAccessToken(authType) { return cr.sendWithPromise('getAccessToken', authType); } /** * Gets the initial settings to initialize the print preview with. * @return {!Promise} */ getInitialSettings() { return cr.sendWithPromise('getInitialSettings'); } /** * Requests the system's print destinations. The promise will be resolved * when all destinations of that type have been retrieved. One or more * 'printers-added' events may be fired in response before resolution. * @param {!print_preview.PrinterType} type The type of destinations to * request. * @return {!Promise} */ getPrinters(type) { return cr.sendWithPromise('getPrinters', type); } /** * Requests the destination's printing capabilities. Returns a promise that * will be resolved with the capabilities if they are obtained successfully. * @param {string} destinationId ID of the destination. * @param {!print_preview.PrinterType} type The destination's printer type. * @return {!Promise} */ getPrinterCapabilities(destinationId, type) { return cr.sendWithPromise( 'getPrinterCapabilities', destinationId, destinationId == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? print_preview.PrinterType.PDF_PRINTER : type); } /** * Requests Chrome to resolve provisional extension destination by granting * the provider extension access to the printer. * @param {string} provisionalDestinationId * @return {!Promise} */ grantExtensionPrinterAccess(provisionalDestinationId) { return cr.sendWithPromise('grantExtensionPrinterAccess', provisionalDestinationId); } /** * Requests that Chrome peform printer setup for the given printer. * @param {string} printerId * @return {!Promise} */ setupPrinter(printerId) { return cr.sendWithPromise('setupPrinter', printerId); } /** * Requests that a preview be generated. The following Web UI events may * be triggered in response: * 'print-preset-options', * 'page-count-ready', * 'page-layout-ready', * 'page-preview-ready' * @param {string} printTicket JSON print ticket for the request. * @param {number} pageCount Page count for the preview request, or -1 if * unknown (first request). * @return {!Promise} Promise that resolves with the unique ID of * the preview UI when the preview has been generated. */ getPreview(printTicket, pageCount) { return cr.sendWithPromise('getPreview', printTicket, pageCount); } /** * Requests that the document be printed. * @param {string} printTicket The serialized print ticket for the print * job. * @return {!Promise} Promise that will resolve when the print request is * finished or rejected. */ print(printTicket) { return cr.sendWithPromise('print', printTicket); } /** Requests that the current pending print request be cancelled. */ cancelPendingPrintRequest() { chrome.send('cancelPendingPrintRequest'); } /** * Sends the app state to be saved in the sticky settings. * @param {string} appStateStr JSON string of the app state to persist. */ saveAppState(appStateStr) { chrome.send('saveAppState', [appStateStr]); } /** Shows the system's native printing dialog. */ showSystemDialog() { assert(!cr.isWindows); chrome.send('showSystemDialog'); } /** * Closes the print preview dialog. * If |isCancel| is true, also sends a message to Print Preview Handler in * order to update UMA statistics. * @param {boolean} isCancel whether this was called due to the user * closing the dialog without printing. */ dialogClose(isCancel) { if (isCancel) chrome.send('closePrintPreviewDialog'); chrome.send('dialogClose'); } /** Hide the print preview dialog and allow the native layer to close it. */ hidePreview() { chrome.send('hidePreview'); } /** * Opens the Google Cloud Print sign-in tab. The DESTINATIONS_RELOAD event * will be dispatched in response. * @param {boolean} addAccount Whether to open an 'add a new account' or * default sign in page. * @return {!Promise} Promise that resolves when the sign in tab has been * closed and the destinations should be reloaded. */ signIn(addAccount) { return cr.sendWithPromise('signIn', addAccount); } /** * Navigates the user to the Chrome printing setting page to manage local * printers and Google cloud printers. */ managePrinters() { chrome.send('managePrinters'); } /** Forces browser to open a new tab with the given URL address. */ forceOpenNewTab(url) { chrome.send('forceOpenNewTab', [url]); } /** * Sends a message to the test, letting it know that an * option has been set to a particular value and that the change has * finished modifying the preview area. */ uiLoadedForTest() { chrome.send('UILoadedForTest'); } /** * Notifies the test that the option it tried to change * had not been changed successfully. */ uiFailedLoadingForTest() { chrome.send('UIFailedLoadingForTest'); } /** * Notifies the metrics handler to record a histogram value. * @param {string} histogram The name of the histogram to record * @param {number} bucket The bucket to record * @param {number} maxBucket The maximum bucket value in the histogram. */ recordInHistogram(histogram, bucket, maxBucket) { chrome.send( 'metricsHandler:recordInHistogram', [histogram, bucket, maxBucket]); } } /** @private {?print_preview.NativeLayer} */ let currentInstance = null; /** * Version of the serialized state of the print preview. * @type {number} * @const * @private */ NativeLayer.SERIALIZED_STATE_VERSION_ = 1; // Export return { NativeLayer: NativeLayer }; }); // 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.exportPath('print_preview'); /** * Enumeration of the types of destinations. * @enum {string} */ print_preview.DestinationType = { GOOGLE: 'google', GOOGLE_PROMOTED: 'google_promoted', LOCAL: 'local', MOBILE: 'mobile' }; /** * Enumeration of the origin types for cloud destinations. * @enum {string} */ print_preview.DestinationOrigin = { LOCAL: 'local', COOKIES: 'cookies', DEVICE: 'device', PRIVET: 'privet', EXTENSION: 'extension', CROS: 'chrome_os', }; /** * Enumeration of the connection statuses of printer destinations. * @enum {string} */ print_preview.DestinationConnectionStatus = { DORMANT: 'DORMANT', OFFLINE: 'OFFLINE', ONLINE: 'ONLINE', UNKNOWN: 'UNKNOWN', UNREGISTERED: 'UNREGISTERED' }; /** * Enumeration specifying whether a destination is provisional and the reason * the destination is provisional. * @enum {string} */ print_preview.DestinationProvisionalType = { /** Destination is not provisional. */ NONE: 'NONE', /** * User has to grant USB access for the destination to its provider. * Used for destinations with extension origin. */ NEEDS_USB_PERMISSION: 'NEEDS_USB_PERMISSION' }; /** * Capabilities of a print destination represented in a CDD. * * @typedef {{ * vendor_capability: !Array<{Object}>, * collate: ({default: (boolean|undefined)}|undefined), * color: ({ * option: !Array<{ * type: (string|undefined), * vendor_id: (string|undefined), * custom_display_name: (string|undefined), * is_default: (boolean|undefined) * }> * }|undefined), * copies: ({default: (number|undefined), * max: (number|undefined)}|undefined), * duplex: ({option: !Array<{type: (string|undefined), * is_default: (boolean|undefined)}>}|undefined), * page_orientation: ({ * option: !Array<{type: (string|undefined), * is_default: (boolean|undefined)}> * }|undefined), * media_size: ({ * option: !Array<{ * type: (string|undefined), * vendor_id: (string|undefined), * custom_display_name: (string|undefined), * is_default: (boolean|undefined) * }> * }|undefined), * dpi: ({ * option: !Array<{ * vendor_id: (string|undefined), * height_microns: number, * width_microns: number, * is_default: (boolean|undefined) * }> * }|undefined) * }} */ print_preview.CddCapabilities; /** * The CDD (Cloud Device Description) describes the capabilities of a print * destination. * * @typedef {{ * version: string, * printer: !print_preview.CddCapabilities, * }} */ print_preview.Cdd; /** * Enumeration of color modes used by Chromium. * @enum {number} */ print_preview.ColorMode = { GRAY: 1, COLOR: 2 }; /** * @typedef {{id: string, * origin: print_preview.DestinationOrigin, * account: string, * capabilities: ?print_preview.Cdd, * displayName: string, * extensionId: string, * extensionName: string}} */ print_preview.RecentDestination; cr.define('print_preview', function() { 'use strict'; /** * Creates a |RecentDestination| to represent |destination| in the app * state. * @param {!print_preview.Destination} destination The destination to store. * @return {!print_preview.RecentDestination} */ function makeRecentDestination(destination) { return { id: destination.id, origin: destination.origin, account: destination.account || '', capabilities: destination.capabilities, displayName: destination.displayName || '', extensionId: destination.extensionId || '', extensionName: destination.extensionName || '', }; } class Destination { /** * Print destination data object that holds data for both local and cloud * destinations. * @param {string} id ID of the destination. * @param {!print_preview.DestinationType} type Type of the destination. * @param {!print_preview.DestinationOrigin} origin Origin of the * destination. * @param {string} displayName Display name of the destination. * @param {boolean} isRecent Whether the destination has been used recently. * @param {!print_preview.DestinationConnectionStatus} connectionStatus * Connection status of the print destination. * @param {{tags: (Array|undefined), * isOwned: (boolean|undefined), * isEnterprisePrinter: (boolean|undefined), * account: (string|undefined), * lastAccessTime: (number|undefined), * cloudID: (string|undefined), * provisionalType: * (print_preview.DestinationProvisionalType|undefined), * extensionId: (string|undefined), * extensionName: (string|undefined), * description: (string|undefined)}=} opt_params Optional * parameters for the destination. */ constructor( id, type, origin, displayName, isRecent, connectionStatus, opt_params) { /** * ID of the destination. * @private {string} */ this.id_ = id; /** * Type of the destination. * @private {!print_preview.DestinationType} */ this.type_ = type; /** * Origin of the destination. * @private {!print_preview.DestinationOrigin} */ this.origin_ = origin; /** * Display name of the destination. * @private {string} */ this.displayName_ = displayName || ''; /** * Whether the destination has been used recently. * @private {boolean} */ this.isRecent_ = isRecent; /** * Tags associated with the destination. * @private {!Array} */ this.tags_ = (opt_params && opt_params.tags) || []; /** * Print capabilities of the destination. * @private {?print_preview.Cdd} */ this.capabilities_ = null; /** * Whether the destination is owned by the user. * @private {boolean} */ this.isOwned_ = (opt_params && opt_params.isOwned) || false; /** * Whether the destination is an enterprise policy controlled printer. * @private {boolean} */ this.isEnterprisePrinter_ = (opt_params && opt_params.isEnterprisePrinter) || false; /** * Account this destination is registered for, if known. * @private {string} */ this.account_ = (opt_params && opt_params.account) || ''; /** * Cache of destination location fetched from tags. * @private {?string} */ this.location_ = null; /** * Printer description. * @private {string} */ this.description_ = (opt_params && opt_params.description) || ''; /** * Connection status of the destination. * @private {!print_preview.DestinationConnectionStatus} */ this.connectionStatus_ = connectionStatus; /** * Number of milliseconds since the epoch when the printer was last * accessed. * @private {number} */ this.lastAccessTime_ = (opt_params && opt_params.lastAccessTime) || Date.now(); /** * Cloud ID for Privet printers. * @private {string} */ this.cloudID_ = (opt_params && opt_params.cloudID) || ''; /** * Extension ID for extension managed printers. * @private {string} */ this.extensionId_ = (opt_params && opt_params.extensionId) || ''; /** * Extension name for extension managed printers. * @private {string} */ this.extensionName_ = (opt_params && opt_params.extensionName) || ''; /** * Different from {@code print_preview.DestinationProvisionalType.NONE} if * the destination is provisional. Provisional destinations cannot be * selected as they are, but have to be resolved first (i.e. extra steps * have to be taken to get actual destination properties, which should * replace the provisional ones). Provisional destination resolvment flow * will be started when the user attempts to select the destination in * search UI. * @private {print_preview.DestinationProvisionalType} */ this.provisionalType_ = (opt_params && opt_params.provisionalType) || print_preview.DestinationProvisionalType.NONE; assert( this.provisionalType_ != print_preview.DestinationProvisionalType .NEEDS_USB_PERMISSION || this.isExtension, 'Provisional USB destination only supprted with extension origin.'); /** * @private {!Array} List of capability types considered color. * @const */ this.COLOR_TYPES_ = ['STANDARD_COLOR', 'CUSTOM_COLOR']; /** * @private {!Array} List of capability types considered * monochrome. * @const */ this.MONOCHROME_TYPES_ = ['STANDARD_MONOCHROME', 'CUSTOM_MONOCHROME']; } /** @return {string} ID of the destination. */ get id() { return this.id_; } /** @return {!print_preview.DestinationType} Type of the destination. */ get type() { return this.type_; } /** * @return {!print_preview.DestinationOrigin} Origin of the destination. */ get origin() { return this.origin_; } /** @return {string} Display name of the destination. */ get displayName() { return this.displayName_; } /** @return {boolean} Whether the destination has been used recently. */ get isRecent() { return this.isRecent_; } /** * @param {boolean} isRecent Whether the destination has been used recently. */ set isRecent(isRecent) { this.isRecent_ = isRecent; } /** * @return {boolean} Whether the user owns the destination. Only applies to * cloud-based destinations. */ get isOwned() { return this.isOwned_; } /** * @return {string} Account this destination is registered for, if known. */ get account() { return this.account_; } /** @return {boolean} Whether the destination is local or cloud-based. */ get isLocal() { return this.origin_ == print_preview.DestinationOrigin.LOCAL || this.origin_ == print_preview.DestinationOrigin.EXTENSION || this.origin_ == print_preview.DestinationOrigin.CROS || (this.origin_ == print_preview.DestinationOrigin.PRIVET && this.connectionStatus_ != print_preview.DestinationConnectionStatus.UNREGISTERED); } /** @return {boolean} Whether the destination is a Privet local printer */ get isPrivet() { return this.origin_ == print_preview.DestinationOrigin.PRIVET; } /** * @return {boolean} Whether the destination is an extension managed * printer. */ get isExtension() { return this.origin_ == print_preview.DestinationOrigin.EXTENSION; } /** * @return {string} The location of the destination, or an empty string if * the location is unknown. */ get location() { if (this.location_ == null) { this.location_ = ''; this.tags_.some(tag => { return Destination.LOCATION_TAG_PREFIXES.some(prefix => { if (tag.startsWith(prefix)) { this.location_ = tag.substring(prefix.length) || ''; return true; } }); }); } return this.location_; } /** * @return {string} The description of the destination, or an empty string, * if it was not provided. */ get description() { return this.description_; } /** * @return {string} Most relevant string to help user to identify this * destination. */ get hint() { if (this.id_ == Destination.GooglePromotedId.DOCS) { return this.account_; } return this.location || this.extensionName || this.description; } /** @return {!Array} Tags associated with the destination. */ get tags() { return this.tags_.slice(0); } /** @return {string} Cloud ID associated with the destination */ get cloudID() { return this.cloudID_; } /** * @return {string} Extension ID associated with the destination. Non-empty * only for extension managed printers. */ get extensionId() { return this.extensionId_; } /** * @return {string} Extension name associated with the destination. * Non-empty only for extension managed printers. */ get extensionName() { return this.extensionName_; } /** @return {?print_preview.Cdd} Print capabilities of the destination. */ get capabilities() { return this.capabilities_; } /** * @param {?print_preview.Cdd} capabilities Print capabilities of the * destination. */ set capabilities(capabilities) { if (capabilities) this.capabilities_ = capabilities; } /** * @return {!print_preview.DestinationConnectionStatus} Connection status * of the print destination. */ get connectionStatus() { return this.connectionStatus_; } /** * @param {!print_preview.DestinationConnectionStatus} status Connection * status of the print destination. */ set connectionStatus(status) { this.connectionStatus_ = status; } /** @return {boolean} Whether the destination is considered offline. */ get isOffline() { return arrayContains( [ print_preview.DestinationConnectionStatus.OFFLINE, print_preview.DestinationConnectionStatus.DORMANT ], this.connectionStatus_); } /** @return {string} Human readable status for offline destination. */ get offlineStatusText() { if (!this.isOffline) { return ''; } const offlineDurationMs = Date.now() - this.lastAccessTime_; let offlineMessageId; if (offlineDurationMs > 31622400000.0) { // One year. offlineMessageId = 'offlineForYear'; } else if (offlineDurationMs > 2678400000.0) { // One month. offlineMessageId = 'offlineForMonth'; } else if (offlineDurationMs > 604800000.0) { // One week. offlineMessageId = 'offlineForWeek'; } else { offlineMessageId = 'offline'; } return loadTimeData.getString(offlineMessageId); } /** * @return {number} Number of milliseconds since the epoch when the printer * was last accessed. */ get lastAccessTime() { return this.lastAccessTime_; } /** @return {string} Relative URL of the destination's icon. */ get iconUrl() { if (this.id_ == Destination.GooglePromotedId.DOCS) { return Destination.IconUrl_.DOCS; } if (this.id_ == Destination.GooglePromotedId.SAVE_AS_PDF) { return Destination.IconUrl_.PDF; } if (this.isEnterprisePrinter) { return Destination.IconUrl_.ENTERPRISE; } if (this.isLocal) { return Destination.IconUrl_.LOCAL_1X; } if (this.type_ == print_preview.DestinationType.MOBILE && this.isOwned_) { return Destination.IconUrl_.MOBILE; } if (this.type_ == print_preview.DestinationType.MOBILE) { return Destination.IconUrl_.MOBILE_SHARED; } if (this.isOwned_) { return Destination.IconUrl_.CLOUD_1X; } return Destination.IconUrl_.CLOUD_SHARED_1X; } /** * @return {string} The srcset="" attribute of a destination. Generally used * for a 2x (e.g. HiDPI) icon. Can be empty or of the format ' 2x'. */ get srcSet() { let srcSetIcon = ''; let iconUrl = this.iconUrl; if (iconUrl == Destination.IconUrl_.LOCAL_1X) { srcSetIcon = Destination.IconUrl_.LOCAL_2X; } else if (iconUrl == Destination.IconUrl_.CLOUD_1X) { srcSetIcon = Destination.IconUrl_.CLOUD_2X; } else if (iconUrl == Destination.IconUrl_.CLOUD_SHARED_1X) { srcSetIcon = Destination.IconUrl_.CLOUD_SHARED_2X; } if (srcSetIcon) { srcSetIcon += ' 2x'; } return srcSetIcon; } /** * @return {!Array} Properties (besides display name) to match * search queries against. */ get extraPropertiesToMatch() { return [this.location, this.description]; } /** * Matches a query against the destination. * @param {!RegExp} query Query to match against the destination. * @return {boolean} {@code true} if the query matches this destination, * {@code false} otherwise. */ matches(query) { return !!this.displayName_.match(query) || !!this.extensionName_.match(query) || this.extraPropertiesToMatch.some(p => p.match(query)); } /** * Gets the destination's provisional type. * @return {print_preview.DestinationProvisionalType} */ get provisionalType() { return this.provisionalType_; } /** * Whether the destinaion is provisional. * @return {boolean} */ get isProvisional() { return this.provisionalType_ != print_preview.DestinationProvisionalType.NONE; } /** * Whether the printer is enterprise policy controlled printer. * @return {boolean} */ get isEnterprisePrinter() { return this.isEnterprisePrinter_; } /** * @return {Object} Color capability of this destination. * @private */ colorCapability_() { return this.capabilities && this.capabilities.printer && this.capabilities.printer.color ? this.capabilities.printer.color : null; } /** * @return {boolean} Whether the printer supports both black and white and * color printing. */ get hasColorCapability() { const capability = this.colorCapability_(); if (!capability || !capability.option) return false; let hasColor = false; let hasMonochrome = false; capability.option.forEach(option => { const type = assert(option.type); hasColor = hasColor || this.COLOR_TYPES_.includes(option.type); hasMonochrome = hasMonochrome || this.MONOCHROME_TYPES_.includes(option.type); }); return hasColor && hasMonochrome; } /** * @param {boolean} isColor Whether to use a color printing mode. * @return {Object} Selected color option. */ getSelectedColorOption(isColor) { const typesToLookFor = isColor ? this.COLOR_TYPES_ : this.MONOCHROME_TYPES_; const capability = this.colorCapability_(); if (!capability || !capability.option) return null; for (let i = 0; i < typesToLookFor.length; i++) { const matchingOptions = capability.option.filter(option => { return option.type == typesToLookFor[i]; }); if (matchingOptions.length > 0) return matchingOptions[0]; } return null; } /** * @param {boolean} isColor Whether to use a color printing mode. * @return {number} Native color model of the destination. */ getNativeColorModel(isColor) { // For non-local printers or printers without capability, native color // model is ignored. const capability = this.colorCapability_(); if (!capability || !capability.option || !this.isLocal) { return isColor ? print_preview.ColorMode.COLOR : print_preview.ColorMode.GRAY; } const selected = this.getSelectedColorOption(isColor); const mode = parseInt(selected ? selected.vendor_id : null, 10); if (isNaN(mode)) { return isColor ? print_preview.ColorMode.COLOR : print_preview.ColorMode.GRAY; } return mode; } /** * @return {Object} The default color option for the destination. */ get defaultColorOption() { const capability = this.colorCapability_(); if (!capability || !capability.option) return null; const defaultOptions = capability.option.filter(option => { return option.is_default; }); return defaultOptions.length != 0 ? defaultOptions[0] : null; } } /** * Prefix of the location destination tag. * @type {!Array} * @const */ Destination.LOCATION_TAG_PREFIXES = ['__cp__location=', '__cp__printer-location=']; /** * Enumeration of Google-promoted destination IDs. * @enum {string} */ Destination.GooglePromotedId = { DOCS: '__google__docs', SAVE_AS_PDF: 'Save as PDF' }; /** * Enumeration of relative icon URLs for various types of destinations. * @enum {string} * @private */ Destination.IconUrl_ = { CLOUD_1X: 'images/1x/printer.png', CLOUD_2X: 'images/2x/printer.png', CLOUD_SHARED_1X: 'images/1x/printer_shared.png', CLOUD_SHARED_2X: 'images/2x/printer_shared.png', LOCAL_1X: 'images/1x/printer.png', LOCAL_2X: 'images/2x/printer.png', MOBILE: 'images/mobile.png', MOBILE_SHARED: 'images/mobile_shared.png', THIRD_PARTY: 'images/third_party.png', PDF: 'images/pdf.png', DOCS: 'images/google_doc.png', ENTERPRISE: 'images/business.svg' }; // Export return { Destination: Destination, makeRecentDestination: makeRecentDestination, }; }); // Copyright 2017 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('print_preview', function() { 'use strict'; /** * Converts DestinationOrigin to PrinterType. * @param {!print_preview.DestinationOrigin} origin The printer's * destination origin. * return {?print_preview.PrinterType} The corresponding PrinterType. * Returns null if no match is found. */ const originToType = function(origin) { if (origin === print_preview.DestinationOrigin.LOCAL || origin === print_preview.DestinationOrigin.CROS) { return print_preview.PrinterType.LOCAL_PRINTER; } if (origin === print_preview.DestinationOrigin.PRIVET) return print_preview.PrinterType.PRIVET_PRINTER; if (origin === print_preview.DestinationOrigin.EXTENSION) return print_preview.PrinterType.EXTENSION_PRINTER; return null; }; class DestinationMatch { /** * A set of key parameters describing a destination used to determine * if two destinations are the same. * @param {!Array} origins Match * destinations from these origins. * @param {RegExp} idRegExp Match destination's id. * @param {RegExp} displayNameRegExp Match destination's displayName. * @param {boolean} skipVirtualDestinations Whether to ignore virtual * destinations, for example, Save as PDF. */ constructor(origins, idRegExp, displayNameRegExp, skipVirtualDestinations) { /** @private {!Array} */ this.origins_ = origins; /** @private {RegExp} */ this.idRegExp_ = idRegExp; /** @private {RegExp} */ this.displayNameRegExp_ = displayNameRegExp; /** @private {boolean} */ this.skipVirtualDestinations_ = skipVirtualDestinations; } /** * @param {string} origin Origin to match. * @return {boolean} Whether the origin is one of the {@code origins_}. */ matchOrigin(origin) { return arrayContains(this.origins_, origin); } /** * @param {string} id Id of the destination. * @param {string} origin Origin of the destination. * @return {boolean} Whether destination is the same as initial. */ matchIdAndOrigin(id, origin) { return this.matchOrigin(origin) && !!this.idRegExp_ && this.idRegExp_.test(id); } /** * @param {!print_preview.Destination} destination Destination to match. * @return {boolean} Whether {@code destination} matches the last user * selected one. */ match(destination) { if (!this.matchOrigin(destination.origin)) { return false; } if (this.idRegExp_ && !this.idRegExp_.test(destination.id)) { return false; } if (this.displayNameRegExp_ && !this.displayNameRegExp_.test(destination.displayName)) { return false; } if (this.skipVirtualDestinations_ && this.isVirtualDestination_(destination)) { return false; } return true; } /** * @param {!print_preview.Destination} destination Destination to check. * @return {boolean} Whether {@code destination} is virtual, in terms of * destination selection. * @private */ isVirtualDestination_(destination) { if (destination.origin == print_preview.DestinationOrigin.LOCAL) { return arrayContains( [print_preview.Destination.GooglePromotedId.SAVE_AS_PDF], destination.id); } return arrayContains( [print_preview.Destination.GooglePromotedId.DOCS], destination.id); } /** * @return {?print_preview.PrinterType} The printer type of this * destination match. Will return null for Cloud destinations. */ getType() { return originToType(this.origins_[0]); } } // Export return {originToType: originToType, DestinationMatch: DestinationMatch}; }); // 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.exportPath('print_preview'); /** * Printer search statuses used by the destination store. * @enum {string} */ print_preview.DestinationStorePrinterSearchStatus = { START: 'start', SEARCHING: 'searching', DONE: 'done' }; cr.define('print_preview', function() { 'use strict'; /** * Localizes printer capabilities. * @param {!print_preview.Cdd} capabilities Printer capabilities to * localize. * @return {!print_preview.Cdd} Localized capabilities. */ const localizeCapabilities = function(capabilities) { if (!capabilities.printer) return capabilities; const mediaSize = capabilities.printer.media_size; if (!mediaSize) return capabilities; for (let i = 0, media; (media = mediaSize.option[i]); i++) { // No need to patch capabilities with localized names provided. if (!media.custom_display_name_localized) { media.custom_display_name = media.custom_display_name || DestinationStore.MEDIA_DISPLAY_NAMES_[media.name] || media.name; } } return capabilities; }; /** * Compare two media sizes by their names. * @param {!Object} a Media to compare. * @param {!Object} b Media to compare. * @return {number} 1 if a > b, -1 if a < b, or 0 if a == b. */ const compareMediaNames = function(a, b) { const nameA = a.custom_display_name_localized || a.custom_display_name; const nameB = b.custom_display_name_localized || b.custom_display_name; return nameA == nameB ? 0 : (nameA > nameB ? 1 : -1); }; /** * Sort printer media sizes. * @param {!print_preview.Cdd} capabilities Printer capabilities to * localize. * @return {!print_preview.Cdd} Localized capabilities. * @private */ const sortMediaSizes = function(capabilities) { if (!capabilities.printer) return capabilities; const mediaSize = capabilities.printer.media_size; if (!mediaSize) return capabilities; // For the standard sizes, separate into categories, as seen in the Cloud // Print CDD guide: // - North American // - Chinese // - ISO // - Japanese // - Other metric // Otherwise, assume they are custom sizes. const categoryStandardNA = []; const categoryStandardCN = []; const categoryStandardISO = []; const categoryStandardJP = []; const categoryStandardMisc = []; const categoryCustom = []; for (let i = 0, media; (media = mediaSize.option[i]); i++) { const name = media.name || 'CUSTOM'; let category; if (name.startsWith('NA_')) { category = categoryStandardNA; } else if ( name.startsWith('PRC_') || name.startsWith('ROC_') || name == 'OM_DAI_PA_KAI' || name == 'OM_JUURO_KU_KAI' || name == 'OM_PA_KAI') { category = categoryStandardCN; } else if (name.startsWith('ISO_')) { category = categoryStandardISO; } else if (name.startsWith('JIS_') || name.startsWith('JPN_')) { category = categoryStandardJP; } else if (name.startsWith('OM_')) { category = categoryStandardMisc; } else { assert(name == 'CUSTOM', 'Unknown media size. Assuming custom'); category = categoryCustom; } category.push(media); } // For each category, sort by name. categoryStandardNA.sort(compareMediaNames); categoryStandardCN.sort(compareMediaNames); categoryStandardISO.sort(compareMediaNames); categoryStandardJP.sort(compareMediaNames); categoryStandardMisc.sort(compareMediaNames); categoryCustom.sort(compareMediaNames); // Then put it all back together. mediaSize.option = categoryStandardNA; mediaSize.option.push( ...categoryStandardCN, ...categoryStandardISO, ...categoryStandardJP, ...categoryStandardMisc, ...categoryCustom); return capabilities; }; class DestinationStore extends cr.EventTarget { /** * A data store that stores destinations and dispatches events when the * data store changes. * @param {!print_preview.UserInfo} userInfo User information repository. * @param {!WebUIListenerTracker} listenerTracker Tracker for WebUI * listeners added in DestinationStore constructor. */ constructor(userInfo, listenerTracker) { super(); /** * Used to fetch local print destinations. * @private {!print_preview.NativeLayer} */ this.nativeLayer_ = print_preview.NativeLayer.getInstance(); /** * User information repository. * @private {!print_preview.UserInfo} */ this.userInfo_ = userInfo; /** * Used to track metrics. * @private {!print_preview.DestinationSearchMetricsContext} */ this.metrics_ = new print_preview.DestinationSearchMetricsContext(); /** * Internal backing store for the data store. * @private {!Array} */ this.destinations_ = []; /** * Cache used for constant lookup of destinations by origin and id. * @private {Object} */ this.destinationMap_ = {}; /** * Currently selected destination. * @private {print_preview.Destination} */ this.selectedDestination_ = null; /** * Whether the destination store will auto select the destination that * matches this set of parameters. * @private {print_preview.DestinationMatch} */ this.autoSelectMatchingDestination_ = null; /** * Event tracker used to track event listeners of the destination store. * @private {!EventTracker} */ this.tracker_ = new EventTracker(); /** * Whether PDF printer is enabled. It's disabled, for example, in App * Kiosk mode. * @private {boolean} */ this.pdfPrinterEnabled_ = false; /** * ID of the system default destination. * @private {string} */ this.systemDefaultDestinationId_ = ''; /** * Used to fetch cloud-based print destinations. * @private {cloudprint.CloudPrintInterface} */ this.cloudPrintInterface_ = null; /** * Maps user account to the list of origins for which destinations are * already loaded. * @private {!Object>} */ this.loadedCloudOrigins_ = {}; /** * ID of a timeout after the initial destination ID is set. If no inserted * destination matches the initial destination ID after the specified * timeout, the first destination in the store will be automatically * selected. * @private {?number} */ this.autoSelectTimeout_ = null; /** * Whether a search for destinations is in progress for each type of * printer. * @private {!Map} */ this.destinationSearchStatus_ = new Map([ [ print_preview.PrinterType.EXTENSION_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ], [ print_preview.PrinterType.PRIVET_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ], [ print_preview.PrinterType.LOCAL_PRINTER, print_preview.DestinationStorePrinterSearchStatus.START ] ]); /** * MDNS service name of destination that we are waiting to register. * @private {?string} */ this.waitForRegisterDestination_ = null; /** * Local destinations are CROS destinations on ChromeOS because they * require extra setup. * @private {!print_preview.DestinationOrigin} */ this.platformOrigin_ = cr.isChromeOS ? print_preview.DestinationOrigin.CROS : print_preview.DestinationOrigin.LOCAL; /** * Whether to default to the system default printer instead of the most * recent destination. * @private {boolean} */ this.useSystemDefaultAsDefault_ = loadTimeData.getBoolean('useSystemDefaultPrinter'); /** * The recent print destinations, set when the store is initialized. * @private {!Array} */ this.recentDestinations_ = []; this.reset_(); this.addWebUIEventListeners_(listenerTracker); } /** * @param {?string=} opt_account Account to filter destinations by. When * null or omitted, all destinations are returned. * @return {!Array} List of destinations * accessible by the {@code account}. */ destinations(opt_account) { if (opt_account) { return this.destinations_.filter(function(destination) { return !destination.account || destination.account == opt_account; }); } return this.destinations_.slice(0); } /** * Gets the destination, if any, matching |account|, |id|, and |origin| in * the destination map. * @param {!print_preview.DestinationOrigin} origin The origin of the * destination. * @param {string} id The destination ID * @param {string} account The account the destination is associated with. * @return {?print_preview.Destination} */ getDestination(origin, id, account) { return this.destinationMap_[this.getDestinationKey_(origin, id, account)]; } /** * @return {print_preview.Destination} The currently selected destination or * {@code null} if none is selected. */ get selectedDestination() { return this.selectedDestination_; } /** @return {boolean} Whether destination selection is pending or not. */ get isAutoSelectDestinationInProgress() { return this.selectedDestination_ == null && this.autoSelectTimeout_ != null; } /** * @return {boolean} Whether a search for print destinations is in progress. */ get isPrintDestinationSearchInProgress() { let isLocalDestinationSearchInProgress = Array.from(this.destinationSearchStatus_.values()) .some( el => el === print_preview.DestinationStorePrinterSearchStatus .SEARCHING); if (isLocalDestinationSearchInProgress) return true; let isCloudDestinationSearchInProgress = !!this.cloudPrintInterface_ && this.cloudPrintInterface_.isCloudDestinationSearchInProgress; return isCloudDestinationSearchInProgress; } /** * Starts listening for relevant WebUI events and adds the listeners to * |listenerTracker|. |listenerTracker| is responsible for removing the * listeners when necessary. * @param {!WebUIListenerTracker} listenerTracker * @private */ addWebUIEventListeners_(listenerTracker) { listenerTracker.add('printers-added', this.onPrintersAdded_.bind(this)); listenerTracker.add( 'reload-printer-list', this.onDestinationsReload.bind(this)); } /** * @param {(?print_preview.Destination | * ?print_preview.RecentDestination)} destination * @return {boolean} Whether the destination is valid. */ isDestinationValid(destination) { return !!destination && !!destination.id && !!destination.origin; } /** * Initializes the destination store. Sets the initially selected * destination. If any inserted destinations match this ID, that destination * will be automatically selected. * @param {boolean} isInAppKioskMode Whether the print preview is in App * Kiosk mode. * @param {string} systemDefaultDestinationId ID of the system default * destination. * @param {?string} serializedDefaultDestinationSelectionRulesStr Serialized * default destination selection rules. * @param {!Array} * recentDestinations The recent print destinations. */ init( isInAppKioskMode, systemDefaultDestinationId, serializedDefaultDestinationSelectionRulesStr, recentDestinations) { this.pdfPrinterEnabled_ = !isInAppKioskMode; this.systemDefaultDestinationId_ = systemDefaultDestinationId; this.createLocalPdfPrintDestination_(); const isRecentDestinationValid = recentDestinations.length > 0 && this.isDestinationValid(recentDestinations[0]); if (!isRecentDestinationValid) { const destinationMatch = this.convertToDestinationMatch_( serializedDefaultDestinationSelectionRulesStr); if (destinationMatch) { this.fetchMatchingDestination_(destinationMatch); return; } } if (this.systemDefaultDestinationId_.length == 0 && !isRecentDestinationValid) { this.selectPdfDestination_(); return; } this.recentDestinations_ = recentDestinations; let origin = null; let id = ''; let account = ''; let name = ''; let capabilities = null; let extensionId = ''; let extensionName = ''; let foundDestination = false; // Run through the destinations forward. As soon as we find a // destination, don't select any future destinations, just mark // them recent. Otherwise, there is a race condition between selecting // destinations/updating the print ticket and this selecting a new // destination that causes random print preview errors. for (let destination of recentDestinations) { origin = destination.origin; id = destination.id; account = destination.account || ''; name = destination.displayName || ''; capabilities = destination.capabilities; extensionId = destination.extensionId || ''; extensionName = destination.extensionName || ''; const candidate = this.destinationMap_[this.getDestinationKey_(origin, id, account)]; if (candidate != null) { candidate.isRecent = true; if (!foundDestination && !this.useSystemDefaultAsDefault_) this.selectDestination(candidate); foundDestination = true; } else if (!foundDestination && !this.useSystemDefaultAsDefault_) { foundDestination = this.fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName); } } if (foundDestination && !this.useSystemDefaultAsDefault_) return; // Try the system default id = this.systemDefaultDestinationId_; origin = id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? print_preview.DestinationOrigin.LOCAL : this.platformOrigin_; account = ''; const systemDefaultCandidate = this.destinationMap_[this.getDestinationKey_(origin, id, account)]; if (systemDefaultCandidate != null) { this.selectDestination(systemDefaultCandidate); return; } if (this.fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName)) { return; } this.selectPdfDestination_(); } /** * Attempts to fetch capabilities of the destination identified by the * provided origin, id and account. * @param {print_preview.DestinationOrigin} origin Destination * origin. * @param {string} id Destination id. * @param {string} account User account destination is registered for. * @param {string} name Destination display name. * @param {?print_preview.Cdd} capabilities Destination capabilities. * @param {string} extensionId Extension ID associated with this * destination. * @param {string} extensionName Extension name associated with this * destination. * @return {boolean} Whether capabilities fetch was successfully started. * @private */ fetchPreselectedDestination_( origin, id, account, name, capabilities, extensionId, extensionName) { this.autoSelectMatchingDestination_ = this.createExactDestinationMatch_(origin, id); const type = print_preview.originToType(origin); if (type == print_preview.PrinterType.LOCAL_PRINTER) { this.nativeLayer_.getPrinterCapabilities(id, type).then( this.onCapabilitiesSet_.bind(this, origin, id), this.onGetCapabilitiesFail_.bind(this, origin, id)); return true; } if (this.cloudPrintInterface_ && (origin == print_preview.DestinationOrigin.COOKIES || origin == print_preview.DestinationOrigin.DEVICE)) { this.cloudPrintInterface_.printer(id, origin, account); return true; } if (origin == print_preview.DestinationOrigin.PRIVET || origin == print_preview.DestinationOrigin.EXTENSION) { // TODO(noamsml): Resolve a specific printer instead of listing all // privet or extension printers in this case. this.startLoadDestinations(type); // Create a fake selectedDestination_ that is not actually in the // destination store. When the real destination is created, this // destination will be overwritten. const params = (origin === print_preview.DestinationOrigin.PRIVET) ? {} : { description: '', extensionId: extensionId, extensionName: extensionName, provisionalType: print_preview.DestinationProvisionalType.NONE }; this.selectedDestination_ = new print_preview.Destination( id, print_preview.DestinationType.LOCAL, origin, name, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, params); if (capabilities) { this.selectedDestination_.capabilities = capabilities; cr.dispatchSimpleEvent( this, DestinationStore.EventType .CACHED_SELECTED_DESTINATION_INFO_READY); } return true; } return false; } /** * Attempts to find a destination matching the provided rules. * @param {!print_preview.DestinationMatch} destinationMatch Rules to match. * @private */ fetchMatchingDestination_(destinationMatch) { this.autoSelectMatchingDestination_ = destinationMatch; const type = destinationMatch.getType(); if (type != null) { // Local, Privet, or Extension. this.startLoadDestinations(type); } else if ( destinationMatch.matchOrigin( print_preview.DestinationOrigin.COOKIES) || destinationMatch.matchOrigin( print_preview.DestinationOrigin.DEVICE)) { this.startLoadCloudDestinations(); } } /** * @param {?string} serializedDefaultDestinationSelectionRulesStr Serialized * default destination selection rules. * @return {?print_preview.DestinationMatch} Creates rules matching * previously selected destination. * @private */ convertToDestinationMatch_(serializedDefaultDestinationSelectionRulesStr) { let matchRules = null; try { if (serializedDefaultDestinationSelectionRulesStr) { matchRules = JSON.parse(serializedDefaultDestinationSelectionRulesStr); } } catch (e) { console.error('Failed to parse defaultDestinationSelectionRules: ' + e); } if (!matchRules) return null; const isLocal = !matchRules.kind || matchRules.kind == 'local'; const isCloud = !matchRules.kind || matchRules.kind == 'cloud'; if (!isLocal && !isCloud) { console.error('Unsupported type: "' + matchRules.kind + '"'); return null; } const origins = []; if (isLocal) { origins.push(print_preview.DestinationOrigin.LOCAL); origins.push(print_preview.DestinationOrigin.PRIVET); origins.push(print_preview.DestinationOrigin.EXTENSION); origins.push(print_preview.DestinationOrigin.CROS); } if (isCloud) { origins.push(print_preview.DestinationOrigin.COOKIES); origins.push(print_preview.DestinationOrigin.DEVICE); } let idRegExp = null; try { if (matchRules.idPattern) { idRegExp = new RegExp(matchRules.idPattern || '.*'); } } catch (e) { console.error('Failed to parse regexp for "id": ' + e); } let displayNameRegExp = null; try { if (matchRules.namePattern) { displayNameRegExp = new RegExp(matchRules.namePattern || '.*'); } } catch (e) { console.error('Failed to parse regexp for "name": ' + e); } return new print_preview.DestinationMatch( origins, idRegExp, displayNameRegExp, true /*skipVirtualDestinations*/); } /** * @return {print_preview.DestinationMatch} Creates rules matching * previously selected destination. * @private */ convertPreselectedToDestinationMatch_() { if (this.isDestinationValid(this.selectedDestination_)) { return this.createExactDestinationMatch_( this.selectedDestination_.origin, this.selectedDestination_.id); } if (this.systemDefaultDestinationId_.length > 0) { return this.createExactDestinationMatch_( this.platformOrigin_, this.systemDefaultDestinationId_); } return null; } /** * @param {string | print_preview.DestinationOrigin} origin Destination * origin. * @param {string} id Destination id. * @return {!print_preview.DestinationMatch} Creates rules matching * provided destination. * @private */ createExactDestinationMatch_(origin, id) { return new print_preview.DestinationMatch( [origin], new RegExp('^' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), null /*displayNameRegExp*/, false /*skipVirtualDestinations*/); } /** * Sets the destination store's Google Cloud Print interface. * @param {!cloudprint.CloudPrintInterface} cloudPrintInterface Interface * to set. */ setCloudPrintInterface(cloudPrintInterface) { assert(this.cloudPrintInterface_ == null); this.cloudPrintInterface_ = cloudPrintInterface; this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SEARCH_DONE, this.onCloudPrintSearchDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.SEARCH_FAILED, this.onCloudPrintSearchDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PRINTER_DONE, this.onCloudPrintPrinterDone_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PRINTER_FAILED, this.onCloudPrintPrinterFailed_.bind(this)); this.tracker_.add( this.cloudPrintInterface_, cloudprint.CloudPrintInterfaceEventType.PROCESS_INVITE_DONE, this.onCloudPrintProcessInviteDone_.bind(this)); } /** * @param {print_preview.Destination} destination Destination to select. */ selectDestination(destination) { this.autoSelectMatchingDestination_ = null; // When auto select expires, DESTINATION_SELECT event has to be dispatched // anyway (see isAutoSelectDestinationInProgress() logic). if (this.autoSelectTimeout_) { clearTimeout(this.autoSelectTimeout_); this.autoSelectTimeout_ = null; } else if (destination == this.selectedDestination_) { return; } if (destination == null) { this.selectedDestination_ = null; cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SELECT); return; } assert( !destination.isProvisional, 'Unable to select provisonal destinations'); // Update and persist selected destination. this.selectedDestination_ = destination; this.selectedDestination_.isRecent = true; // Adjust metrics. if (destination.cloudID && this.destinations_.some(function(otherDestination) { return otherDestination.cloudID == destination.cloudID && otherDestination != destination; })) { this.metrics_.record( destination.isPrivet ? print_preview.Metrics.DestinationSearchBucket .PRIVET_DUPLICATE_SELECTED : print_preview.Metrics.DestinationSearchBucket .CLOUD_DUPLICATE_SELECTED); } // Notify about selected destination change. cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SELECT); // Request destination capabilities from backend, since they are not // known yet. if (destination.capabilities == null) { const type = print_preview.originToType(destination.origin); if (type !== null) { this.nativeLayer_.getPrinterCapabilities(destination.id, type) .then( (caps) => this.onCapabilitiesSet_( destination.origin, destination.id, caps), () => this.onGetCapabilitiesFail_( destination.origin, destination.origin)); } else { assert( this.cloudPrintInterface_ != null, 'Cloud destination selected, but GCP is not enabled'); this.cloudPrintInterface_.printer( destination.id, destination.origin, destination.account); } } else { cr.dispatchSimpleEvent( this, DestinationStore.EventType.SELECTED_DESTINATION_CAPABILITIES_READY); } } /** * Attempt to resolve the capabilities for a Chrome OS printer. * @param {!print_preview.Destination} destination The destination which * requires resolution. * @return {!Promise} */ resolveCrosDestination(destination) { assert(destination.origin == print_preview.DestinationOrigin.CROS); return this.nativeLayer_.setupPrinter(destination.id); } /** * Attempts to resolve a provisional destination. * @param {!print_preview.Destination} destination Provisional destination * that should be resolved. */ resolveProvisionalDestination(destination) { assert( destination.provisionalType == print_preview.DestinationProvisionalType.NEEDS_USB_PERMISSION, 'Provisional type cannot be resolved.'); this.nativeLayer_.grantExtensionPrinterAccess(destination.id) .then( destinationInfo => { /** * Removes the destination from the store and replaces it with a * destination created from the resolved destination properties, * if any are reported. Then sends a * PROVISIONAL_DESTINATION_RESOLVED event. */ this.removeProvisionalDestination_(destination.id); const parsedDestination = print_preview.parseExtensionDestination(destinationInfo); this.insertIntoStore_(parsedDestination); this.dispatchProvisionalDestinationResolvedEvent_( destination.id, parsedDestination); }, () => { /** * The provisional destination is removed from the store and a * PROVISIONAL_DESTINATION_RESOLVED event is dispatched with a * null destination. */ this.removeProvisionalDestination_(destination.id); this.dispatchProvisionalDestinationResolvedEvent_( destination.id, null); }); } /** * Selects 'Save to PDF' destination (since it always exists). * @private */ selectPdfDestination_() { const saveToPdfKey = this.getDestinationKey_( print_preview.DestinationOrigin.LOCAL, print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, ''); this.selectDestination( this.destinationMap_[saveToPdfKey] || this.destinations_[0] || null); } /** * Attempts to select system default destination with a fallback to * 'Save to PDF' destination. * @private */ selectDefaultDestination_() { if (this.systemDefaultDestinationId_.length > 0) { if (this.autoSelectMatchingDestination_ && !this.autoSelectMatchingDestination_.matchIdAndOrigin( this.systemDefaultDestinationId_, this.platformOrigin_)) { if (this.fetchPreselectedDestination_( this.platformOrigin_, this.systemDefaultDestinationId_, '' /*account*/, '' /*name*/, null /*capabilities*/, '' /*extensionId*/, '' /*extensionName*/)) { return; } } } this.selectPdfDestination_(); } /** * Initiates loading of destinations. * @param{print_preview.PrinterType} type The type of destinations to load. */ startLoadDestinations(type) { if (this.destinationSearchStatus_.get(type) === print_preview.DestinationStorePrinterSearchStatus.DONE) { return; } this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.SEARCHING); this.nativeLayer_.getPrinters(type).then( this.onDestinationSearchDone_.bind(this, type), () => { // Will be rejected by C++ for privet printers if privet printing // is disabled. assert(type === print_preview.PrinterType.PRIVET_PRINTER); this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.DONE); }); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_STARTED); } /** * Initiates loading of cloud destinations. * @param {print_preview.DestinationOrigin=} opt_origin Search destinations * for the specified origin only. */ startLoadCloudDestinations(opt_origin) { if (this.cloudPrintInterface_ != null) { const origins = this.loadedCloudOrigins_[this.userInfo_.activeUser] || []; if (origins.length == 0 || (opt_origin && origins.indexOf(opt_origin) < 0)) { this.cloudPrintInterface_.search( this.userInfo_.activeUser, opt_origin); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_STARTED); } } } /** Requests load of COOKIE based cloud destinations. */ reloadUserCookieBasedDestinations() { const origins = this.loadedCloudOrigins_[this.userInfo_.activeUser] || []; if (origins.indexOf(print_preview.DestinationOrigin.COOKIES) >= 0) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); } else { this.startLoadCloudDestinations( print_preview.DestinationOrigin.COOKIES); } } /** Initiates loading of all known destination types. */ startLoadAllDestinations() { this.startLoadCloudDestinations(); for (const printerType of Object.values(print_preview.PrinterType)) { if (printerType !== print_preview.PrinterType.PDF_PRINTER) this.startLoadDestinations(printerType); } } /** * Wait for a privet device to be registered. */ waitForRegister(id) { const privetType = print_preview.PrinterType.PRIVET_PRINTER; this.nativeLayer_.getPrinters(privetType) .then(this.onDestinationSearchDone_.bind(this, privetType)); this.waitForRegisterDestination_ = id; } /** * Removes the provisional destination with ID |provisionalId| from * |destinationMap_| and |destinations_|. * @param{string} provisionalId The provisional destination ID. * @private */ removeProvisionalDestination_(provisionalId) { this.destinations_ = this.destinations_.filter( function(el) { if (el.id == provisionalId) { delete this.destinationMap_[this.getKey_(el)]; return false; } return true; }, this); } /** * Dispatches the PROVISIONAL_DESTINATION_RESOLVED event for id * |provisionalId| and destination |destination|. * @param {string} provisionalId The ID of the destination that was * resolved. * @param {?print_preview.Destination} destination Information about the * destination if it was resolved successfully. */ dispatchProvisionalDestinationResolvedEvent_(provisionalId, destination) { const event = new Event( DestinationStore.EventType.PROVISIONAL_DESTINATION_RESOLVED); event.provisionalId = provisionalId; event.destination = destination; this.dispatchEvent(event); } /** * Inserts {@code destination} to the data store and dispatches a * DESTINATIONS_INSERTED event. * @param {!print_preview.Destination} destination Print destination to * insert. * @private */ insertDestination_(destination) { if (this.insertIntoStore_(destination)) { this.destinationsInserted_(destination); } } /** * Inserts multiple {@code destinations} to the data store and dispatches * single DESTINATIONS_INSERTED event. * @param {!Array>} destinations Print * destinations to insert. * @private */ insertDestinations_(destinations) { let inserted = false; destinations.forEach(destination => { if (Array.isArray(destination)) { // privet printers return arrays of 1 or 2 printers inserted = destination.reduce(function(soFar, d) { return this.insertIntoStore_(d) || soFar; }, inserted); } else { inserted = this.insertIntoStore_(destination) || inserted; } }); if (inserted) { this.destinationsInserted_(); } } /** * Dispatches DESTINATIONS_INSERTED event. In auto select mode, tries to * update selected destination to match * {@code autoSelectMatchingDestination_}. * @param {print_preview.Destination=} opt_destination The only destination * that was changed or skipped if possibly more than one destination was * changed. Used as a hint to limit destination search scope against * {@code autoSelectMatchingDestination_}. */ destinationsInserted_(opt_destination) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATIONS_INSERTED); if (this.autoSelectMatchingDestination_) { const destinationsToSearch = opt_destination && [opt_destination] || this.destinations_; destinationsToSearch.some(function(destination) { if (this.autoSelectMatchingDestination_.match(destination)) { this.selectDestination(destination); return true; } }, this); } } /** * Updates an existing print destination with capabilities and display name * information. If the destination doesn't already exist, it will be added. * @param {!print_preview.Destination} destination Destination to update. * @private */ updateDestination_(destination) { assert(destination.constructor !== Array, 'Single printer expected'); destination.capabilities_ = localizeCapabilities(assert(destination.capabilities_)); if (print_preview.originToType(destination.origin) !== print_preview.PrinterType.LOCAL_PRINTER) { destination.capabilities_ = sortMediaSizes(destination.capabilities_); } const existingDestination = this.destinationMap_[this.getKey_(destination)]; if (existingDestination != null) { existingDestination.capabilities = destination.capabilities; } else { this.insertDestination_(destination); } if (this.selectedDestination_ && (existingDestination == this.selectedDestination_ || destination == this.selectedDestination_)) { cr.dispatchSimpleEvent( this, DestinationStore.EventType.SELECTED_DESTINATION_CAPABILITIES_READY); } } /** * Called when loading of extension managed printers is done. * @private */ endExtensionPrinterSearch_() { // Clear initially selected (cached) extension destination if it hasn't // been found among reported extension destinations. if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchOrigin( print_preview.DestinationOrigin.EXTENSION) && this.selectedDestination_ && this.selectedDestination_.isExtension) { this.selectDefaultDestination_(); } } /** * Inserts a destination into the store without dispatching any events. * @param {!print_preview.Destination} destination The destination to be * inserted. * @return {boolean} Whether the inserted destination was not already in the * store. * @private */ insertIntoStore_(destination) { const key = this.getKey_(destination); const existingDestination = this.destinationMap_[key]; if (existingDestination == null) { destination.isRecent |= this.recentDestinations_.some(function(recent) { return ( destination.id == recent.id && destination.origin == recent.origin); }, this); this.destinations_.push(destination); this.destinationMap_[key] = destination; return true; } if (existingDestination.connectionStatus == print_preview.DestinationConnectionStatus.UNKNOWN && destination.connectionStatus != print_preview.DestinationConnectionStatus.UNKNOWN) { existingDestination.connectionStatus = destination.connectionStatus; return true; } return false; } /** * Creates a local PDF print destination. * @private */ createLocalPdfPrintDestination_() { // TODO(alekseys): Create PDF printer in the native code and send its // capabilities back with other local printers. if (this.pdfPrinterEnabled_) { this.insertDestination_(new print_preview.Destination( print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.LOCAL, loadTimeData.getString('printToPDF'), false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE)); } } /** * Resets the state of the destination store to its initial state. * @private */ reset_() { this.destinations_ = []; this.destinationMap_ = {}; this.selectDestination(null); this.loadedCloudOrigins_ = {}; for (const printerType of Object.values(print_preview.PrinterType)) { if (printerType !== print_preview.PrinterType.PDF_PRINTER) { this.destinationSearchStatus_.set( printerType, print_preview.DestinationStorePrinterSearchStatus.START); } } clearTimeout(this.autoSelectTimeout_); this.autoSelectTimeout_ = setTimeout( this.selectDefaultDestination_.bind(this), DestinationStore.AUTO_SELECT_TIMEOUT_); } /** * Called when destination search is complete for some type of printer. * @param {!print_preview.PrinterType} type The type of printers that are * done being retreived. */ onDestinationSearchDone_(type) { this.destinationSearchStatus_.set( type, print_preview.DestinationStorePrinterSearchStatus.DONE); cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); if (type === print_preview.PrinterType.EXTENSION_PRINTER) this.endExtensionPrinterSearch_(); } /** * Called when the native layer retrieves the capabilities for the selected * local destination. Updates the destination with new capabilities if the * destination already exists, otherwise it creates a new destination and * then updates its capabilities. * @param {!print_preview.DestinationOrigin} origin The origin of the * print destination. * @param {string} id The id of the print destination. * @param {!print_preview.CapabilitiesResponse} settingsInfo Contains * the capabilities of the print destination, and information about * the destination except in the case of extension printers. * @private */ onCapabilitiesSet_(origin, id, settingsInfo) { let dest = null; if (origin !== print_preview.DestinationOrigin.PRIVET) { const key = this.getDestinationKey_(origin, id, ''); dest = this.destinationMap_[key]; } if (!dest) { // Ignore unrecognized extension printers if (!settingsInfo.printer) { assert(origin === print_preview.DestinationOrigin.EXTENSION); return; } dest = print_preview.parseDestination( print_preview.originToType(origin), assert(settingsInfo.printer)); } if (dest) { if ((origin === print_preview.DestinationOrigin.LOCAL || origin === print_preview.DestinationOrigin.CROS) && dest.capabilities) { // If capabilities are already set for this destination ignore new // results. This prevents custom margins from being cleared as long // as the user does not change to a new non-recent destination. return; } const updateDestination = destination => { destination.capabilities = settingsInfo.capabilities; this.updateDestination_(destination); }; if (Array.isArray(dest)) { dest.forEach(updateDestination); } else { updateDestination(dest); } } } /** * Called when a request to get a local destination's print capabilities * fails. If the destination is the initial destination, auto-select another * destination instead. * @param {print_preview.DestinationOrigin} origin The origin type of the * failed destination. * @param {string} destinationId The destination ID that failed. * @private */ onGetCapabilitiesFail_(origin, destinationId) { console.warn( 'Failed to get print capabilities for printer ' + destinationId); if (this.selectedDestination_ && this.selectedDestination_.id == destinationId) { const event = new Event(DestinationStore.EventType.SELECTED_DESTINATION_INVALID); event.destinationId = destinationId; this.dispatchEvent(event); } if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchIdAndOrigin( destinationId, origin)) { this.selectDefaultDestination_(); } } /** * Called when the /search call completes, either successfully or not. * In case of success, stores fetched destinations. * @param {Event} event Contains the request result. * @private */ onCloudPrintSearchDone_(event) { if (event.printers) { this.insertDestinations_(event.printers); } if (event.searchDone) { const origins = this.loadedCloudOrigins_[event.user] || []; if (origins.indexOf(event.origin) < 0) { this.loadedCloudOrigins_[event.user] = origins.concat([event.origin]); } } cr.dispatchSimpleEvent( this, DestinationStore.EventType.DESTINATION_SEARCH_DONE); } /** * Called when /printer call completes. Updates the specified destination's * print capabilities. * @param {Event} event Contains detailed information about the * destination. * @private */ onCloudPrintPrinterDone_(event) { this.updateDestination_(event.printer); } /** * Called when the Google Cloud Print interface fails to lookup a * destination. Selects another destination if the failed destination was * the initial destination. * @param {Object} event Contains the ID of the destination that was failed * to be looked up. * @private */ onCloudPrintPrinterFailed_(event) { if (this.autoSelectMatchingDestination_ && this.autoSelectMatchingDestination_.matchIdAndOrigin( event.destinationId, event.destinationOrigin)) { console.error( 'Failed to fetch last used printer caps: ' + event.destinationId); this.selectDefaultDestination_(); } } /** * Called when printer sharing invitation was processed successfully. * @param {Event} event Contains detailed information about the invite and * newly accepted destination (if known). * @private */ onCloudPrintProcessInviteDone_(event) { if (event.accept && event.printer) { // Hint the destination list to promote this new destination. event.printer.isRecent = true; this.insertDestination_(event.printer); } } /** * Called when a printer or printers are detected after sending getPrinters * from the native layer. * @param {print_preview.PrinterType} type The type of printer(s) added. * @param {!Array} printers * Information about the printers that have been retrieved. */ onPrintersAdded_(type, printers) { if (type == print_preview.PrinterType.PRIVET_PRINTER) { const printer = /** !print_preview.PrivetPrinterDescription */ (printers[0]); if (printer.serviceName == this.waitForRegisterDestination_ && !printer.isUnregistered) { this.waitForRegisterDestination_ = null; this.onDestinationsReload(); return; } } this.insertDestinations_(printers.map( printer => print_preview.parseDestination(type, printer))); } /** * Called from print preview after the user was requested to sign in, and * did so successfully. */ onDestinationsReload() { this.reset_(); this.autoSelectMatchingDestination_ = this.convertPreselectedToDestinationMatch_(); this.createLocalPdfPrintDestination_(); this.startLoadAllDestinations(); } // TODO(vitalybuka): Remove three next functions replacing Destination.id // and Destination.origin by complex ID. /** * Returns key to be used with {@code destinationMap_}. * @param {!print_preview.DestinationOrigin} origin Destination origin. * @param {string} id Destination id. * @param {string} account User account destination is registered for. * @private */ getDestinationKey_(origin, id, account) { return origin + '/' + id + '/' + account; } /** * Returns key to be used with {@code destinationMap_}. * @param {!print_preview.Destination} destination Destination. * @private */ getKey_(destination) { return this.getDestinationKey_( destination.origin, destination.id, destination.account); } } /** * Event types dispatched by the data store. * @enum {string} */ DestinationStore.EventType = { DESTINATION_SEARCH_DONE: 'print_preview.DestinationStore.DESTINATION_SEARCH_DONE', DESTINATION_SEARCH_STARTED: 'print_preview.DestinationStore.DESTINATION_SEARCH_STARTED', DESTINATION_SELECT: 'print_preview.DestinationStore.DESTINATION_SELECT', DESTINATIONS_INSERTED: 'print_preview.DestinationStore.DESTINATIONS_INSERTED', PROVISIONAL_DESTINATION_RESOLVED: 'print_preview.DestinationStore.PROVISIONAL_DESTINATION_RESOLVED', CACHED_SELECTED_DESTINATION_INFO_READY: 'print_preview.DestinationStore.CACHED_SELECTED_DESTINATION_INFO_READY', SELECTED_DESTINATION_CAPABILITIES_READY: 'print_preview.DestinationStore' + '.SELECTED_DESTINATION_CAPABILITIES_READY', SELECTED_DESTINATION_INVALID: 'print_preview.DestinationStore.SELECTED_DESTINATION_INVALID', }; /** * Delay in milliseconds before the destination store ignores the initial * destination ID and just selects any printer (since the initial destination * was not found). * @private {number} * @const */ DestinationStore.AUTO_SELECT_TIMEOUT_ = 15000; /** * Maximum amount of time spent searching for extension destinations, in * milliseconds. * @private {number} * @const */ DestinationStore.EXTENSION_SEARCH_DURATION_ = 5000; /** * Human readable names for media sizes in the cloud print CDD. * https://developers.google.com/cloud-print/docs/cdd * @private {Object} * @const */ DestinationStore.MEDIA_DISPLAY_NAMES_ = { 'ISO_2A0': '2A0', 'ISO_A0': 'A0', 'ISO_A0X3': 'A0x3', 'ISO_A1': 'A1', 'ISO_A10': 'A10', 'ISO_A1X3': 'A1x3', 'ISO_A1X4': 'A1x4', 'ISO_A2': 'A2', 'ISO_A2X3': 'A2x3', 'ISO_A2X4': 'A2x4', 'ISO_A2X5': 'A2x5', 'ISO_A3': 'A3', 'ISO_A3X3': 'A3x3', 'ISO_A3X4': 'A3x4', 'ISO_A3X5': 'A3x5', 'ISO_A3X6': 'A3x6', 'ISO_A3X7': 'A3x7', 'ISO_A3_EXTRA': 'A3 Extra', 'ISO_A4': 'A4', 'ISO_A4X3': 'A4x3', 'ISO_A4X4': 'A4x4', 'ISO_A4X5': 'A4x5', 'ISO_A4X6': 'A4x6', 'ISO_A4X7': 'A4x7', 'ISO_A4X8': 'A4x8', 'ISO_A4X9': 'A4x9', 'ISO_A4_EXTRA': 'A4 Extra', 'ISO_A4_TAB': 'A4 Tab', 'ISO_A5': 'A5', 'ISO_A5_EXTRA': 'A5 Extra', 'ISO_A6': 'A6', 'ISO_A7': 'A7', 'ISO_A8': 'A8', 'ISO_A9': 'A9', 'ISO_B0': 'B0', 'ISO_B1': 'B1', 'ISO_B10': 'B10', 'ISO_B2': 'B2', 'ISO_B3': 'B3', 'ISO_B4': 'B4', 'ISO_B5': 'B5', 'ISO_B5_EXTRA': 'B5 Extra', 'ISO_B6': 'B6', 'ISO_B6C4': 'B6C4', 'ISO_B7': 'B7', 'ISO_B8': 'B8', 'ISO_B9': 'B9', 'ISO_C0': 'C0', 'ISO_C1': 'C1', 'ISO_C10': 'C10', 'ISO_C2': 'C2', 'ISO_C3': 'C3', 'ISO_C4': 'C4', 'ISO_C5': 'C5', 'ISO_C6': 'C6', 'ISO_C6C5': 'C6C5', 'ISO_C7': 'C7', 'ISO_C7C6': 'C7C6', 'ISO_C8': 'C8', 'ISO_C9': 'C9', 'ISO_DL': 'Envelope DL', 'ISO_RA0': 'RA0', 'ISO_RA1': 'RA1', 'ISO_RA2': 'RA2', 'ISO_SRA0': 'SRA0', 'ISO_SRA1': 'SRA1', 'ISO_SRA2': 'SRA2', 'JIS_B0': 'B0 (JIS)', 'JIS_B1': 'B1 (JIS)', 'JIS_B10': 'B10 (JIS)', 'JIS_B2': 'B2 (JIS)', 'JIS_B3': 'B3 (JIS)', 'JIS_B4': 'B4 (JIS)', 'JIS_B5': 'B5 (JIS)', 'JIS_B6': 'B6 (JIS)', 'JIS_B7': 'B7 (JIS)', 'JIS_B8': 'B8 (JIS)', 'JIS_B9': 'B9 (JIS)', 'JIS_EXEC': 'Executive (JIS)', 'JPN_CHOU2': 'Choukei 2', 'JPN_CHOU3': 'Choukei 3', 'JPN_CHOU4': 'Choukei 4', 'JPN_HAGAKI': 'Hagaki', 'JPN_KAHU': 'Kahu Envelope', 'JPN_KAKU2': 'Kaku 2', 'JPN_OUFUKU': 'Oufuku Hagaki', 'JPN_YOU4': 'You 4', 'NA_10X11': '10x11', 'NA_10X13': '10x13', 'NA_10X14': '10x14', 'NA_10X15': '10x15', 'NA_11X12': '11x12', 'NA_11X15': '11x15', 'NA_12X19': '12x19', 'NA_5X7': '5x7', 'NA_6X9': '6x9', 'NA_7X9': '7x9', 'NA_9X11': '9x11', 'NA_A2': 'A2', 'NA_ARCH_A': 'Arch A', 'NA_ARCH_B': 'Arch B', 'NA_ARCH_C': 'Arch C', 'NA_ARCH_D': 'Arch D', 'NA_ARCH_E': 'Arch E', 'NA_ASME_F': 'ASME F', 'NA_B_PLUS': 'B-plus', 'NA_C': 'C', 'NA_C5': 'C5', 'NA_D': 'D', 'NA_E': 'E', 'NA_EDP': 'EDP', 'NA_EUR_EDP': 'European EDP', 'NA_EXECUTIVE': 'Executive', 'NA_F': 'F', 'NA_FANFOLD_EUR': 'FanFold European', 'NA_FANFOLD_US': 'FanFold US', 'NA_FOOLSCAP': 'FanFold German Legal', 'NA_GOVT_LEGAL': 'Government Legal', 'NA_GOVT_LETTER': 'Government Letter', 'NA_INDEX_3X5': 'Index 3x5', 'NA_INDEX_4X6': 'Index 4x6', 'NA_INDEX_4X6_EXT': 'Index 4x6 ext', 'NA_INDEX_5X8': '5x8', 'NA_INVOICE': 'Invoice', 'NA_LEDGER': 'Tabloid', // Ledger in portrait is called Tabloid. 'NA_LEGAL': 'Legal', 'NA_LEGAL_EXTRA': 'Legal extra', 'NA_LETTER': 'Letter', 'NA_LETTER_EXTRA': 'Letter extra', 'NA_LETTER_PLUS': 'Letter plus', 'NA_MONARCH': 'Monarch', 'NA_NUMBER_10': 'Envelope #10', 'NA_NUMBER_11': 'Envelope #11', 'NA_NUMBER_12': 'Envelope #12', 'NA_NUMBER_14': 'Envelope #14', 'NA_NUMBER_9': 'Envelope #9', 'NA_PERSONAL': 'Personal', 'NA_QUARTO': 'Quarto', 'NA_SUPER_A': 'Super A', 'NA_SUPER_B': 'Super B', 'NA_WIDE_FORMAT': 'Wide format', 'OM_DAI_PA_KAI': 'Dai-pa-kai', 'OM_FOLIO': 'Folio', 'OM_FOLIO_SP': 'Folio SP', 'OM_INVITE': 'Invite Envelope', 'OM_ITALIAN': 'Italian Envelope', 'OM_JUURO_KU_KAI': 'Juuro-ku-kai', 'OM_LARGE_PHOTO': 'Large photo', 'OM_OFICIO': 'Oficio', 'OM_PA_KAI': 'Pa-kai', 'OM_POSTFIX': 'Postfix Envelope', 'OM_SMALL_PHOTO': 'Small photo', 'PRC_1': 'prc1 Envelope', 'PRC_10': 'prc10 Envelope', 'PRC_16K': 'prc 16k', 'PRC_2': 'prc2 Envelope', 'PRC_3': 'prc3 Envelope', 'PRC_32K': 'prc 32k', 'PRC_4': 'prc4 Envelope', 'PRC_5': 'prc5 Envelope', 'PRC_6': 'prc6 Envelope', 'PRC_7': 'prc7 Envelope', 'PRC_8': 'prc8 Envelope', 'ROC_16K': 'ROC 16K', 'ROC_8K': 'ROC 8k', }; // Export return {DestinationStore: DestinationStore}; }); // 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('print_preview', function() { 'use strict'; /** * @param{!print_preview.PrinterType} type The type of printer to parse. * @param{!print_preview.LocalDestinationInfo | * !print_preview.PrivetPrinterDescription | * !print_preview.ProvisionalDestinationInfo} printer Information * about the printer. Type expected depends on |type|: * For LOCAL_PRINTER => print_preview.LocalDestinationInfo * For PRIVET_PRINTER => print_preview.PrivetPrinterDescription * For EXTENSION_PRINTER => print_preview.ProvisionalDestinationInfo * @return {!Array | !print_preview.Destination} */ function parseDestination(type, printer) { if (type === print_preview.PrinterType.LOCAL_PRINTER) { return parseLocalDestination( /** @type {!print_preview.LocalDestinationInfo} */ (printer)); } if (type === print_preview.PrinterType.PRIVET_PRINTER) { return parsePrivetDestination( /** @type {!print_preview.PrivetPrinterDescription} */ (printer)); } if (type === print_preview.PrinterType.EXTENSION_PRINTER) { return parseExtensionDestination( /** @type {!print_preview.ProvisionalDestinationInfo} */ (printer)); } assertNotReached('Unknown printer type ' + type); return []; } /** * Parses a local print destination. * @param {!print_preview.LocalDestinationInfo} destinationInfo Information * describing a local print destination. * @return {!print_preview.Destination} Parsed local print destination. */ function parseLocalDestination(destinationInfo) { const options = { description: destinationInfo.printerDescription, isEnterprisePrinter: destinationInfo.cupsEnterprisePrinter }; if (destinationInfo.printerOptions) { // Convert options into cloud print tags format. options.tags = Object.keys(destinationInfo.printerOptions).map(function(key) { return '__cp__' + key + '=' + this[key]; }, destinationInfo.printerOptions); } return new print_preview.Destination( destinationInfo.deviceName, print_preview.DestinationType.LOCAL, cr.isChromeOS ? print_preview.DestinationOrigin.CROS : print_preview.DestinationOrigin.LOCAL, destinationInfo.printerName, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, options); } /** * Parses a privet destination as one or more local printers. * @param {!print_preview.PrivetPrinterDescription} destinationInfo Object * that describes a privet printer. * @return {!print_preview.Destination | * !Array} Parsed destination info. */ function parsePrivetDestination(destinationInfo) { const returnedPrinters = []; if (destinationInfo.hasLocalPrinting) { returnedPrinters.push(new print_preview.Destination( destinationInfo.serviceName, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.PRIVET, destinationInfo.name, false /*isRecent*/, print_preview.DestinationConnectionStatus.ONLINE, {cloudID: destinationInfo.cloudID})); } if (destinationInfo.isUnregistered) { returnedPrinters.push(new print_preview.Destination( destinationInfo.serviceName, print_preview.DestinationType.GOOGLE, print_preview.DestinationOrigin.PRIVET, destinationInfo.name, false /*isRecent*/, print_preview.DestinationConnectionStatus.UNREGISTERED)); } return returnedPrinters.length === 1 ? returnedPrinters[0] : returnedPrinters; } /** * Parses an extension destination from an extension supplied printer * description. * @param {!print_preview.ProvisionalDestinationInfo} destinationInfo Object * describing an extension printer. * @return {!print_preview.Destination} Parsed destination. */ function parseExtensionDestination(destinationInfo) { const provisionalType = destinationInfo.provisional ? print_preview.DestinationProvisionalType.NEEDS_USB_PERMISSION : print_preview.DestinationProvisionalType.NONE; return new print_preview.Destination( destinationInfo.id, print_preview.DestinationType.LOCAL, print_preview.DestinationOrigin.EXTENSION, destinationInfo.name, false /* isRecent */, print_preview.DestinationConnectionStatus.ONLINE, { description: destinationInfo.description || '', extensionId: destinationInfo.extensionId, extensionName: destinationInfo.extensionName || '', provisionalType: provisionalType }); } // Export return { parseDestination: parseDestination, parseExtensionDestination: parseExtensionDestination }; }); // 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.exportPath('print_preview.ticket_items'); /** * Enumeration of the orientations of margins. * @enum {string} */ print_preview.ticket_items.CustomMarginsOrientation = { TOP: 'top', RIGHT: 'right', BOTTOM: 'bottom', LEFT: 'left' }; cr.define('print_preview', function() { 'use strict'; class Margins { /** * Creates a Margins object that holds four margin values in points. * @param {number} top The top margin in pts. * @param {number} right The right margin in pts. * @param {number} bottom The bottom margin in pts. * @param {number} left The left margin in pts. */ constructor(top, right, bottom, left) { /** * Backing store for the margin values in points. * @type {!Object< * !print_preview.ticket_items.CustomMarginsOrientation, number>} * @private */ this.value_ = {}; this.value_[print_preview.ticket_items.CustomMarginsOrientation.TOP] = top; this.value_[print_preview.ticket_items.CustomMarginsOrientation.RIGHT] = right; this.value_[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM] = bottom; this.value_[print_preview.ticket_items.CustomMarginsOrientation.LEFT] = left; } /** * Parses a margins object from the given serialized state. * @param {Object} state Serialized representation of the margins created by * the {@code serialize} method. * @return {!print_preview.Margins} New margins instance. */ static parse(state) { return new print_preview.Margins( state[print_preview.ticket_items.CustomMarginsOrientation.TOP] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.RIGHT] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM] || 0, state[print_preview.ticket_items.CustomMarginsOrientation.LEFT] || 0); } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin value to get. * @return {number} Value of the margin of the given orientation. */ get(orientation) { return this.value_[orientation]; } /** * @param {!print_preview.ticket_items.CustomMarginsOrientation} * orientation Specifies the margin to set. * @param {number} value Updated value of the margin in points to modify. * @return {!print_preview.Margins} A new copy of |this| with the * modification made to the specified margin. */ set(orientation, value) { const newValue = this.clone_(); newValue[orientation] = value; return new Margins( newValue[print_preview.ticket_items.CustomMarginsOrientation.TOP], newValue[print_preview.ticket_items.CustomMarginsOrientation.RIGHT], newValue[print_preview.ticket_items.CustomMarginsOrientation.BOTTOM], newValue[print_preview.ticket_items.CustomMarginsOrientation.LEFT]); } /** * @param {print_preview.Margins} other The other margins object to compare * against. * @return {boolean} Whether this margins object is equal to another. */ equals(other) { if (other == null) { return false; } for (const orientation in this.value_) { if (this.value_[orientation] != other.value_[orientation]) { return false; } } return true; } /** @return {Object} A serialized representation of the margins. */ serialize() { return this.clone_(); } /** * @return {Object} Cloned state of the margins. * @private */ clone_() { const clone = {}; for (const o in this.value_) { clone[o] = this.value_[o]; } return clone; } } // Export return {Margins: Margins}; }); // 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('print_preview', function() { 'use strict'; class UserInfo extends cr.EventTarget { /** * Repository which stores information about the user. Events are dispatched * when the information changes. */ constructor() { super(); /** * Email address of the logged in user or {@code null} if no user is * logged in. In case of Google multilogin, can be changed by the user. * @private {?string} */ this.activeUser_ = null; /** * Email addresses of the logged in users or empty array if no user is * logged in. {@code null} if not known yet. * @private {?Array} */ this.users_ = null; } /** @return {boolean} Whether user accounts are already retrieved. */ get initialized() { return this.users_ != null; } /** @return {boolean} Whether user is logged in or not. */ get loggedIn() { return !!this.activeUser; } /** * @return {?string} Email address of the logged in user or {@code null} if * no user is logged. */ get activeUser() { return this.activeUser_; } /** * Changes active user. * @param {?string} activeUser Email address for the user to be set as * active. */ set activeUser(activeUser) { if (!!activeUser && this.activeUser_ != activeUser) { this.activeUser_ = activeUser; cr.dispatchSimpleEvent(this, UserInfo.EventType.ACTIVE_USER_CHANGED); } } /** * @return {?Array} Email addresses of the logged in users or * empty array if no user is logged in. {@code null} if not known yet. */ get users() { return this.users_; } /** * Sets logged in user accounts info. * @param {string} activeUser Active user account (email). * @param {!Array} users List of currently logged in accounts. */ setUsers(activeUser, users) { this.activeUser_ = activeUser; this.users_ = users || []; cr.dispatchSimpleEvent(this, UserInfo.EventType.USERS_CHANGED); } } /** * Enumeration of event types dispatched by the user info. * @enum {string} */ UserInfo.EventType = { ACTIVE_USER_CHANGED: 'print_preview.UserInfo.ACTIVE_USER_CHANGED', USERS_CHANGED: 'print_preview.UserInfo.USERS_CHANGED' }; return {UserInfo: UserInfo}; }); // 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('print_preview', function() { 'use strict'; /** * Object used to measure usage statistics. * @constructor */ function Metrics() {} /** * Enumeration of buckets that a user can enter while using the destination * search widget. * @enum {number} */ Metrics.DestinationSearchBucket = { // Used when the print destination search widget is shown. DESTINATION_SHOWN: 0, // Used when the user selects a print destination. DESTINATION_CLOSED_CHANGED: 1, // Used when the print destination search widget is closed without selecting // a print destination. DESTINATION_CLOSED_UNCHANGED: 2, // Used when the Google Cloud Print promotion (shown in the destination // search widget) is shown to the user. SIGNIN_PROMPT: 3, // Used when the user chooses to sign-in to their Google account. SIGNIN_TRIGGERED: 4, // Used when a user selects the Privet printer in a pair of duplicate // Privet and cloud printers. PRIVET_DUPLICATE_SELECTED: 5, // Used when a user selects the cloud printer in a pair of duplicate // Privet and cloud printers. CLOUD_DUPLICATE_SELECTED: 6, // Used when a user sees a register promo for a cloud print printer. REGISTER_PROMO_SHOWN: 7, // Used when a user selects a register promo for a cloud print printer. REGISTER_PROMO_SELECTED: 8, // User changed active account. ACCOUNT_CHANGED: 9, // User tried to log into another account. ADD_ACCOUNT_SELECTED: 10, // Printer sharing invitation was shown to the user. INVITATION_AVAILABLE: 11, // User accepted printer sharing invitation. INVITATION_ACCEPTED: 12, // User rejected printer sharing invitation. INVITATION_REJECTED: 13, // Max value. DESTINATION_SEARCH_MAX_BUCKET: 14 }; /** * Print settings UI usage metrics buckets. * @enum {number} */ Metrics.PrintSettingsUiBucket = { // Advanced settings dialog is shown. ADVANCED_SETTINGS_DIALOG_SHOWN: 0, // Advanced settings dialog is closed without saving a selection. ADVANCED_SETTINGS_DIALOG_CANCELED: 1, // 'More/less settings' expanded. MORE_SETTINGS_CLICKED: 2, // 'More/less settings' collapsed. LESS_SETTINGS_CLICKED: 3, // User printed with extra settings expanded. PRINT_WITH_SETTINGS_EXPANDED: 4, // User printed with extra settings collapsed. PRINT_WITH_SETTINGS_COLLAPSED: 5, // Max value. PRINT_SETTINGS_UI_MAX_BUCKET: 6 }; /** * A context for recording a value in a specific UMA histogram. * @param {string} histogram The name of the histogram to be recorded in. * @param {number} maxBucket The max value for the last histogram bucket. * @constructor */ function MetricsContext(histogram, maxBucket) { /** @private {string} */ this.histogram_ = histogram; /** @private {number} */ this.maxBucket_ = maxBucket; /** @private {!print_preview.NativeLayer} */ this.nativeLayer_ = print_preview.NativeLayer.getInstance(); } MetricsContext.prototype = { /** * Record a histogram value in UMA. If specified value is larger than the * max bucket value, record the value in the largest bucket * @param {number} bucket Value to record. */ record: function(bucket) { this.nativeLayer_.recordInHistogram( this.histogram_, (bucket > this.maxBucket_) ? this.maxBucket_ : bucket, this.maxBucket_); } }; /** * Destination Search specific usage statistics context. * @constructor * @extends {print_preview.MetricsContext} */ function DestinationSearchMetricsContext() { MetricsContext.call( this, 'PrintPreview.DestinationAction', Metrics.DestinationSearchBucket.DESTINATION_SEARCH_MAX_BUCKET); } DestinationSearchMetricsContext.prototype = { __proto__: MetricsContext.prototype }; /** * Print settings UI specific usage statistics context. * @constructor * @extends {print_preview.MetricsContext} */ function PrintSettingsUiMetricsContext() { MetricsContext.call( this, 'PrintPreview.PrintSettingsUi', Metrics.PrintSettingsUiBucket.PRINT_SETTINGS_UI_MAX_BUCKET); } PrintSettingsUiMetricsContext.prototype = { __proto__: MetricsContext.prototype }; // Export return { Metrics: Metrics, MetricsContext: MetricsContext, DestinationSearchMetricsContext: DestinationSearchMetricsContext, PrintSettingsUiMetricsContext: PrintSettingsUiMetricsContext }; }); // 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. /** * @param {string} toTest The string to be tested. * @return {boolean} True if |toTest| contains only digits. Leading and trailing * whitespace is allowed. */ function isInteger(toTest) { const numericExp = /^\s*[0-9]+\s*$/; return numericExp.test(toTest); } /** * Returns true if |value| is a valid non zero positive integer. * @param {string} value The string to be tested. * @return {boolean} true if the |value| is valid non zero positive integer. */ function isPositiveInteger(value) { return isInteger(value) && parseInt(value, 10) > 0; } /** * Returns true if the contents of the two arrays are equal. * @param {Array<{from: number, to: number}>} array1 The first array. * @param {Array<{from: number, to: number}>} array2 The second array. * @return {boolean} true if the arrays are equal. */ function areArraysEqual(array1, array2) { if (array1.length != array2.length) return false; for (let i = 0; i < array1.length; i++) if (array1[i] !== array2[i]) return false; return true; } /** * Returns true if the contents of the two page ranges are equal. * @param {Array} array1 The first array. * @param {Array} array2 The second array. * @return {boolean} true if the arrays are equal. */ function areRangesEqual(array1, array2) { if (array1.length != array2.length) return false; for (let i = 0; i < array1.length; i++) if (array1[i].from != array2[i].from || array1[i].to != array2[i].to) { return false; } return true; } /** * Removes duplicate elements from |inArray| and returns a new array. * |inArray| is not affected. It assumes that |inArray| is already sorted. * @param {!Array} inArray The array to be processed. * @return {!Array} The array after processing. */ function removeDuplicates(inArray) { const out = []; if (inArray.length == 0) return out; out.push(inArray[0]); for (let i = 1; i < inArray.length; ++i) if (inArray[i] != inArray[i - 1]) out.push(inArray[i]); return out; } /** @enum {number} */ const PageRangeStatus = { NO_ERROR: 0, SYNTAX_ERROR: -1, LIMIT_ERROR: -2 }; /** * Returns a list of ranges in |pageRangeText|. The ranges are * listed in the order they appear in |pageRangeText| and duplicates are not * eliminated. If |pageRangeText| is not valid, PageRangeStatus.SYNTAX_ERROR * is returned. * A valid selection has a parsable format and every page identifier is * greater than 0 unless wildcards are used(see examples). * If a page is greater than |totalPageCount|, PageRangeStatus.LIMIT_ERROR * is returned. * If |totalPageCount| is 0 or undefined function uses impossibly large number * instead. * Wildcard the first number must be larger than 0 and less or equal then * |totalPageCount|. If it's missed then 1 is used as the first number. * Wildcard the second number must be larger then the first number. If it's * missed then |totalPageCount| is used as the second number. * Example: "1-4, 9, 3-6, 10, 11" is valid, assuming |totalPageCount| >= 11. * Example: "1-4, -6" is valid, assuming |totalPageCount| >= 6. * Example: "2-" is valid, assuming |totalPageCount| >= 2, means from 2 to the * end. * Example: "4-2, 11, -6" is invalid. * Example: "-" is valid, assuming |totalPageCount| >= 1. * Example: "1-4dsf, 11" is invalid regardless of |totalPageCount|. * @param {string} pageRangeText The text to be checked. * @param {number=} opt_totalPageCount The total number of pages. * @return {!PageRangeStatus|!Array<{from: number, to: number}>} */ function pageRangeTextToPageRanges(pageRangeText, opt_totalPageCount) { if (pageRangeText == '') { return []; } const MAX_PAGE_NUMBER = 1000000000; const totalPageCount = opt_totalPageCount ? opt_totalPageCount : MAX_PAGE_NUMBER; const regex = /^\s*([0-9]*)\s*-\s*([0-9]*)\s*$/; const parts = pageRangeText.split(/,/); const pageRanges = []; for (let i = 0; i < parts.length; ++i) { const match = parts[i].match(regex); if (match) { if (!isPositiveInteger(match[1]) && match[1] !== '') return PageRangeStatus.SYNTAX_ERROR; if (!isPositiveInteger(match[2]) && match[2] !== '') return PageRangeStatus.SYNTAX_ERROR; const from = match[1] ? parseInt(match[1], 10) : 1; const to = match[2] ? parseInt(match[2], 10) : totalPageCount; if (from > to) return PageRangeStatus.SYNTAX_ERROR; if (to > totalPageCount) return PageRangeStatus.LIMIT_ERROR; pageRanges.push({'from': from, 'to': to}); } else { if (!isPositiveInteger(parts[i])) return PageRangeStatus.SYNTAX_ERROR; const singlePageNumber = parseInt(parts[i], 10); if (singlePageNumber > totalPageCount) return PageRangeStatus.LIMIT_ERROR; pageRanges.push({'from': singlePageNumber, 'to': singlePageNumber}); } } return pageRanges; } /** * Returns a list of pages defined by |pagesRangeText|. The pages are * listed in the order they appear in |pageRangeText| and duplicates are not * eliminated. If |pageRangeText| is not valid according or * |totalPageCount| undefined [1,2,...,totalPageCount] is returned. * See pageRangeTextToPageRanges for details. * @param {string} pageRangeText The text to be checked. * @param {number} totalPageCount The total number of pages. * @return {!Array} A list of all pages. */ function pageRangeTextToPageList(pageRangeText, totalPageCount) { const pageRanges = pageRangeTextToPageRanges(pageRangeText, totalPageCount); const pageList = []; if (Array.isArray(pageRanges)) { for (let i = 0; i < pageRanges.length; ++i) { for (let j = pageRanges[i].from; j <= Math.min(pageRanges[i].to, totalPageCount); ++j) { pageList.push(j); } } } if (pageList.length == 0) { for (let j = 1; j <= totalPageCount; ++j) pageList.push(j); } return pageList; } /** * @param {!Array} pageList The list to be processed. * @return {!Array} The contents of |pageList| in ascending order and * without any duplicates. |pageList| is not affected. */ function pageListToPageSet(pageList) { let pageSet = []; if (pageList.length == 0) return pageSet; pageSet = pageList.slice(0); pageSet.sort(function(a, b) { return /** @type {number} */ (a) - /** @type {number} */ (b); }); pageSet = removeDuplicates(pageSet); return pageSet; } /** * @param {!HTMLElement} element Element to check for visibility. * @return {boolean} Whether the given element is visible. */ function getIsVisible(element) { return !element.hidden; } /** * Shows or hides an element. * @param {!HTMLElement} element Element to show or hide. * @param {boolean} isVisible Whether the element should be visible or not. */ function setIsVisible(element, isVisible) { element.hidden = !isVisible; } /** * @param {!Array} array Array to check for item. * @param {*} item Item to look for in array. * @return {boolean} Whether the item is in the array. */ function arrayContains(array, item) { return array.indexOf(item) != -1; } /** * @param {!Array} localizedStrings An array * of strings with corresponding locales. * @param {string} locale Locale to look the string up for. * @return {string} A string for the requested {@code locale}. An empty string * if there's no string for the specified locale found. */ function getStringForLocale(localizedStrings, locale) { locale = locale.toLowerCase(); for (let i = 0; i < localizedStrings.length; i++) { if (localizedStrings[i].locale.toLowerCase() == locale) return localizedStrings[i].value; } return ''; } /** * @param {!Array} localizedStrings An array * of strings with corresponding locales. * @return {string} A string for the current locale. An empty string if there's * no string for the current locale found. */ function getStringForCurrentLocale(localizedStrings) { // First try to find an exact match and then look for the language only. return getStringForLocale(localizedStrings, navigator.language) || getStringForLocale(localizedStrings, navigator.language.split('-')[0]); } // 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('print_preview', function() { 'use strict'; class DocumentInfo extends cr.EventTarget { /** * Data model which contains information related to the document to print. */ constructor() { super(); /** * Whether the document is styled by CSS media styles. * @private {boolean} */ this.hasCssMediaStyles_ = false; /** * Whether the document has selected content. * @private {boolean} */ this.hasSelection_ = false; /** * Whether the document to print is modifiable (i.e. can be reflowed). * @private {boolean} */ this.isModifiable_ = true; /** * Whether scaling of the document is prohibited. * @private {boolean} */ this.isScalingDisabled_ = false; /** * Scaling required to fit to page. * @private {number} */ this.fitToPageScaling_ = 100; /** * Margins of the document in points. * @private {print_preview.Margins} */ this.margins_ = null; /** * Number of pages in the document to print. * @private {number} */ this.pageCount_ = 0; /** * Size of the pages of the document in points. Actual page-related * information won't be set until preview generation occurs, so use * a default value until then. This way, the print ticket store will be * valid even if no preview can be generated. * @private {!print_preview.Size} */ this.pageSize_ = new print_preview.Size(612, 792); // 8.5"x11" /** * Printable area of the document in points. * @private {!print_preview.PrintableArea} */ this.printableArea_ = new print_preview.PrintableArea( new print_preview.Coordinate2d(0, 0), this.pageSize_); /** * Title of document. * @private {string} */ this.title_ = ''; /** * Whether this data model has been initialized. * @private {boolean} */ this.isInitialized_ = false; } /** @return {boolean} Whether the document is styled by CSS media styles. */ get hasCssMediaStyles() { return this.hasCssMediaStyles_; } /** @return {boolean} Whether the document has selected content. */ get hasSelection() { return this.hasSelection_; } /** * @return {boolean} Whether the document to print is modifiable (i.e. can * be reflowed). */ get isModifiable() { return this.isModifiable_; } /** @return {boolean} Whether scaling of the document is prohibited. */ get isScalingDisabled() { return this.isScalingDisabled_; } /** @return {number} Scaling required to fit to page. */ get fitToPageScaling() { return this.fitToPageScaling_; } /** @return {print_preview.Margins} Margins of the document in points. */ get margins() { return this.margins_; } /** @return {number} Number of pages in the document to print. */ get pageCount() { return this.pageCount_; } /** * @return {!print_preview.Size} Size of the pages of the document in * points. */ get pageSize() { return this.pageSize_; } /** * @return {!print_preview.PrintableArea} Printable area of the document in * points. */ get printableArea() { return this.printableArea_; } /** @return {string} Title of document. */ get title() { return this.title_; } /** * Initializes the state of the data model and dispatches a CHANGE event. * @param {boolean} isModifiable Whether the document is modifiable. * @param {string} title Title of the document. * @param {boolean} hasSelection Whether the document has user-selected * content. */ init(isModifiable, title, hasSelection) { this.isModifiable_ = isModifiable; this.title_ = title; this.hasSelection_ = hasSelection; this.isInitialized_ = true; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } /** * Updates whether scaling is disabled for the document and dispatches a * CHANGE event. * @param {boolean} isScalingDisabled Whether scaling of the document is * prohibited. */ updateIsScalingDisabled(isScalingDisabled) { if (this.isInitialized_ && this.isScalingDisabled_ != isScalingDisabled) { this.isScalingDisabled_ = isScalingDisabled; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } /** * Updates the total number of pages in the document and dispatches a CHANGE * event. * @param {number} pageCount Number of pages in the document. */ updatePageCount(pageCount) { if (this.isInitialized_ && this.pageCount_ != pageCount) { this.pageCount_ = pageCount; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } /** * Updates information about each page and dispatches a CHANGE event. * @param {!print_preview.PrintableArea} printableArea Printable area of the * document in points. * @param {!print_preview.Size} pageSize Size of the pages of the document * in points. * @param {boolean} hasCssMediaStyles Whether the document is styled by CSS * media styles. * @param {print_preview.Margins} margins Margins of the document in points. */ updatePageInfo(printableArea, pageSize, hasCssMediaStyles, margins) { if (this.isInitialized_ && (!this.printableArea_.equals(printableArea) || !this.pageSize_.equals(pageSize) || this.hasCssMediaStyles_ != hasCssMediaStyles || this.margins_ == null || !this.margins_.equals(margins))) { this.printableArea_ = printableArea; this.pageSize_ = pageSize; this.hasCssMediaStyles_ = hasCssMediaStyles; this.margins_ = margins; cr.dispatchSimpleEvent(this, DocumentInfo.EventType.CHANGE); } } } /** * Event types dispatched by this data model. * @enum {string} */ DocumentInfo.EventType = {CHANGE: 'print_preview.DocumentInfo.CHANGE'}; // Export return {DocumentInfo: DocumentInfo}; }); // 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('print_preview', function() { 'use strict'; class Size { /** * Immutable two-dimensional size. * @param {number} width Width of the size. * @param {number} height Height of the size. */ constructor(width, height) { /** * Width of the size. * @type {number} * @private */ this.width_ = width; /** * Height of the size. * @type {number} * @private */ this.height_ = height; } /** @return {number} Width of the size. */ get width() { return this.width_; } /** @return {number} Height of the size. */ get height() { return this.height_; } /** * @param {print_preview.Size} other Other size object to compare against. * @return {boolean} Whether this size object is equal to another. */ equals(other) { return other != null && this.width_ == other.width_ && this.height_ == other.height_; } } // Export return {Size: Size}; }); // 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('print_preview', function() { 'use strict'; class Coordinate2d { /** * Immutable two dimensional point in space. The units of the dimensions are * undefined. * @param {number} x X-dimension of the point. * @param {number} y Y-dimension of the point. */ constructor(x, y) { /** * X-dimension of the point. * @type {number} * @private */ this.x_ = x; /** * Y-dimension of the point. * @type {number} * @private */ this.y_ = y; } /** @return {number} X-dimension of the point. */ get x() { return this.x_; } /** @return {number} Y-dimension of the point. */ get y() { return this.y_; } /** * @param {number} x Amount to translate in the X dimension. * @param {number} y Amount to translate in the Y dimension. * @return {!print_preview.Coordinate2d} A new two-dimensional point * translated along the X and Y dimensions. */ translate(x, y) { return new Coordinate2d(this.x_ + x, this.y_ + y); } /** * @param {number} factor Amount to scale the X and Y dimensions. * @return {!print_preview.Coordinate2d} A new two-dimensional point scaled * by the given factor. */ scale(factor) { return new Coordinate2d(this.x_ * factor, this.y_ * factor); } /** * @param {print_preview.Coordinate2d} other The point to compare against. * @return {boolean} Whether another point is equal to this one. */ equals(other) { return other != null && this.x_ == other.x_ && this.y_ == other.y_; } } // Export return {Coordinate2d: Coordinate2d}; }); // 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.exportPath('print_preview'); /** * Enumeration of measurement unit types. * @enum {number} */ print_preview.MeasurementSystemUnitType = { METRIC: 0, // millimeters IMPERIAL: 1 // inches }; /** * @typedef {{precision: number, * decimalPlaces: number, * ptsPerUnit: number, * unitSymbol: string}} */ print_preview.MeasurementSystemPrefs; cr.define('print_preview', function() { 'use strict'; class MeasurementSystem { /** * Measurement system of the print preview. Used to parse and serialize * point measurements into the system's local units (e.g. millimeters, * inches). * @param {string} thousandsDelimeter Delimeter between thousands digits. * @param {string} decimalDelimeter Delimeter between integers and decimals. * @param {!print_preview.MeasurementSystemUnitType} unitType Measurement * unit type of the system. */ constructor(thousandsDelimeter, decimalDelimeter, unitType) { /** * The thousands delimeter to use when displaying numbers. * @private {string} */ this.thousandsDelimeter_ = thousandsDelimeter || ','; /** * The decimal delimeter to use when displaying numbers. * @private {string} */ this.decimalDelimeter_ = decimalDelimeter || '.'; assert(measurementSystemPrefs.has(unitType)); /** * The measurement system preferences based on the unit type. * @private {!print_preview.MeasurementSystemPrefs} */ this.measurementSystemPrefs_ = measurementSystemPrefs.get(unitType); } /** @return {string} The unit type symbol of the measurement system. */ get unitSymbol() { return this.measurementSystemPrefs_.unitSymbol; } /** * @return {string} The thousands delimeter character of the measurement * system. */ get thousandsDelimeter() { return this.thousandsDelimeter_; } /** * @return {string} The decimal delimeter character of the measurement * system. */ get decimalDelimeter() { return this.decimalDelimeter_; } /** * Sets the measurement system based on the delimeters and unit type. * @param {string} thousandsDelimeter The thousands delimeter to use * @param {string} decimalDelimeter The decimal delimeter to use * @param {!print_preview.MeasurementSystemUnitType} unitType Measurement * unit type of the system. */ setSystem(thousandsDelimeter, decimalDelimeter, unitType) { this.thousandsDelimeter_ = thousandsDelimeter; this.decimalDelimeter_ = decimalDelimeter; assert(measurementSystemPrefs.has(unitType)); this.measurementSystemPrefs_ = measurementSystemPrefs.get(unitType); } /** * Rounds a value in the local system's units to the appropriate precision. * @param {number} value Value to round. * @return {number} Rounded value. */ roundValue(value) { const precision = this.measurementSystemPrefs_.precision; const roundedValue = Math.round(value / precision) * precision; // Truncate return +roundedValue.toFixed(this.measurementSystemPrefs_.decimalPlaces); } /** * @param {number} pts Value in points to convert to local units. * @return {number} Value in local units. */ convertFromPoints(pts) { return pts / this.measurementSystemPrefs_.ptsPerUnit; } /** * @param {number} localUnits Value in local units to convert to points. * @return {number} Value in points. */ convertToPoints(localUnits) { return localUnits * this.measurementSystemPrefs_.ptsPerUnit; } } /** * Maximum resolution and number of decimal places for local unit values. * @private {!Map} */ const measurementSystemPrefs = new Map([ [ print_preview.MeasurementSystemUnitType.METRIC, { precision: 0.5, decimalPlaces: 1, ptsPerUnit: 72.0 / 25.4, unitSymbol: 'mm' } ], [ print_preview.MeasurementSystemUnitType.IMPERIAL, {precision: 0.01, decimalPlaces: 2, ptsPerUnit: 72.0, unitSymbol: '"'} ] ]); // Export return {MeasurementSystem: MeasurementSystem}; }); // 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('print_preview', function() { 'use strict'; class PrintableArea { /** * Object describing the printable area of a page in the document. * @param {!print_preview.Coordinate2d} origin Top left corner of the * printable area of the document. * @param {!print_preview.Size} size Size of the printable area of the * document. */ constructor(origin, size) { /** * Top left corner of the printable area of the document. * @type {!print_preview.Coordinate2d} * @private */ this.origin_ = origin; /** * Size of the printable area of the document. * @type {!print_preview.Size} * @private */ this.size_ = size; } /** * @return {!print_preview.Coordinate2d} Top left corner of the printable * area of the document. */ get origin() { return this.origin_; } /** * @return {!print_preview.Size} Size of the printable area of the document. */ get size() { return this.size_; } /** * @param {print_preview.PrintableArea} other Other printable area to check * for equality. * @return {boolean} Whether another printable area is equal to this one. */ equals(other) { return other != null && this.origin_.equals(other.origin_) && this.size_.equals(other.size_); } } // Export return {PrintableArea: PrintableArea}; }); // Copyright 2017 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: 'print-preview-header', behaviors: [SettingsBehavior], properties: { /** @type {!print_preview.Destination} */ destination: Object, /** @type {!print_preview_new.State} */ state: Object, /** @private {boolean} */ printInProgress_: { type: Boolean, notify: true, value: false, }, /** * @private {?string} Null value indicates that there is no error or * state to display in the summary. */ currentErrorOrState_: { type: String, computed: 'computeErrorOrStateString_(state.*, ' + 'settings.copies.valid, settings.scaling.valid, ' + 'settings.pages.valid, printInProgress_)' }, /** * @private {{numPages: number, * numSheets: number, * pagesLabel: string, * summaryLabel: string}} */ labelInfo_: { type: Object, computed: 'getLabelInfo_(currentErrorOrState_, destination.id, ' + 'settings.copies.value, settings.pages.value, ' + 'settings.duplex.value)' }, }, /** @private */ onPrintButtonTap_: function() { this.printInProgress_ = true; }, /** @private */ onCancelButtonTap_: function() { this.printInProgress_ = false; }, /** * @return {boolean} * @private */ isPdfOrDrive_: function() { return this.destination.id == print_preview.Destination.GooglePromotedId.SAVE_AS_PDF || this.destination.id == print_preview.Destination.GooglePromotedId.DOCS; }, /** * @return {string} * @private */ getPrintButton_: function() { return loadTimeData.getString( this.isPdfOrDrive_() ? 'saveButton' : 'printButton'); }, /** * @return {?string} * @private */ computeErrorOrStateString_: function() { if (this.state.cloudPrintError != '') return this.state.cloudPrintError; if (this.state.privetExtensionError != '') return this.state.privetExtensionError; if (this.state.invalidSettings || this.state.previewFailed || this.state.previewLoading || !this.getSetting('copies').valid || !this.getSetting('scaling').valid || !this.getSetting('pages').valid) { return ''; } if (this.printInProgress_) { return loadTimeData.getString( this.isPdfOrDrive_() ? 'saving' : 'printing'); } return null; }, /** * @return {{numPages: number, * numSheets: number, * pagesLabel: string, * summaryLabel: string}} * @private */ getLabelInfo_: function() { const saveToPdfOrDrive = this.isPdfOrDrive_(); let numPages = this.getSetting('pages').value.length; let numSheets = numPages; if (!saveToPdfOrDrive && this.getSetting('duplex').value) { numSheets = Math.ceil(numPages / 2); } const copies = /** @type {number} */ (this.getSetting('copies').value); numSheets *= copies; numPages *= copies; const pagesLabel = loadTimeData.getString('printPreviewPageLabelPlural'); let summaryLabel; if (numSheets > 1) { summaryLabel = saveToPdfOrDrive ? pagesLabel : loadTimeData.getString('printPreviewSheetsLabelPlural'); } else { summaryLabel = loadTimeData.getString( saveToPdfOrDrive ? 'printPreviewPageLabelSingular' : 'printPreviewSheetsLabelSingular'); } return { numPages: numPages, numSheets: numSheets, pagesLabel: pagesLabel, summaryLabel: summaryLabel }; }, /** * @return {boolean} * @private */ printButtonDisabled_: function() { return this.currentErrorOrState_ != null; }, /** * @return {string} * @private */ getSummary_: function() { let html = this.currentErrorOrState_; if (html != null) return html; const labelInfo = this.labelInfo_; if (labelInfo.numPages != labelInfo.numSheets) { html = loadTimeData.getStringF( 'printPreviewSummaryFormatLong', '' + labelInfo.numSheets.toLocaleString() + '', '' + labelInfo.summaryLabel + '', labelInfo.numPages.toLocaleString(), labelInfo.pagesLabel); } else { html = loadTimeData.getStringF( 'printPreviewSummaryFormatShort', '' + labelInfo.numSheets.toLocaleString() + '', '' + labelInfo.summaryLabel + ''); } // Removing extra spaces from within the string. html = html.replace(/\s{2,}/g, ' '); return html; }, /** * @return {string} * @private */ getSummaryLabel_: function() { if (this.currentErrorOrState_ != null) return this.currentErrorOrState_; const labelInfo = this.labelInfo_; if (labelInfo.numPages != labelInfo.numSheets) { return loadTimeData.getStringF( 'printPreviewSummaryFormatLong', labelInfo.numSheets.toLocaleString(), labelInfo.summaryLabel, labelInfo.numPages.toLocaleString(), labelInfo.pagesLabel); } return loadTimeData.getStringF( 'printPreviewSummaryFormatShort', labelInfo.numSheets.toLocaleString(), labelInfo.summaryLabel); } }); // Copyright 2017 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.exportPath('print_preview_new'); /** * @typedef {{ * value: *, * valid: boolean, * available: boolean, * updatesPreview: boolean * }} */ print_preview_new.Setting; /** @polymerBehavior */ const SettingsBehavior = { properties: { /** @type {Object} */ settings: { type: Object, notify: true, }, }, /** * @param {string} settingName Name of the setting to get. * @return {print_preview_new.Setting} The setting object. */ getSetting: function(settingName) { const setting = /** @type {print_preview_new.Setting} */ ( this.get(settingName, this.settings)); assert(!!setting, 'Setting is missing: ' + settingName); return setting; }, /** * @param {string} settingName Name of the setting to set * @param {boolean | string | number | Array | Object} value The value to set * the setting to. */ setSetting: function(settingName, value) { const setting = this.getSetting(settingName); this.set(`settings.${settingName}.value`, value); }, /** * @param {string} settingName Name of the setting to set * @param {boolean} valid Whether the setting value is currently valid. */ setSettingValid: function(settingName, valid) { const setting = this.getSetting(settingName); // Should not set the setting to invalid if it is not available, as there // is no way for the user to change the value in this case. if (!valid) assert(setting.available, 'Setting is not available: ' + settingName); this.set(`settings.${settingName}.valid`, valid); } }; // Copyright 2017 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: 'print-preview-settings-section', }); // Copyright 2017 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.exportPath('print_preview_new'); /** * @typedef {{ * is_default: (boolean | undefined), * custom_display_name: (string | undefined), * custom_display_name_localized: (Array | * undefined), * name: (string | undefined), * }} */ print_preview_new.SelectOption; Polymer({ is: 'print-preview-settings-select', behaviors: [SettingsBehavior], properties: { /** @type {{ option: Array }} */ capability: Object, /** @type {string} */ settingName: String, }, /** @param {string} value The value to select. */ selectValue: function(value) { this.$$('select').value = value; }, /** * @param {!print_preview_new.SelectOption} option Option to get the value * for. * @return {string} Value for the option. * @private */ getValue_: function(option) { return JSON.stringify(option); }, /** * @param {!print_preview_new.SelectOption} option Option to get the display * name for. * @return {string} Display name for the option. * @private */ getDisplayName_: function(option) { let displayName = option.custom_display_name; if (!displayName && option.custom_display_name_localized) { displayName = getStringForCurrentLocale( assert(option.custom_display_name_localized)); } return displayName || option.name || ''; }, /** @private */ onChange_: function() { let value = null; try { value = JSON.parse(this.$$('select').value); } catch (e) { assertNotReached(); return; } this.setSetting(this.settingName, /** @type {Object} */ (value)); }, }); // Copyright 2017 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: 'print-preview-destination-settings', properties: { /** @type {!print_preview.Destination} */ destination: Object, /** @private {boolean} */ loadingDestination_: Boolean, }, /** @override */ ready: function() { this.loadingDestination_ = true; // Simulate transition from spinner to destination. setTimeout(this.doneLoading_.bind(this), 5000); }, /** @private */ doneLoading_: function() { this.loadingDestination_ = false; }, }); // Copyright 2017 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.exportPath('print_preview_new'); /** @enum {number} */ const PagesInputErrorState = { NO_ERROR: 0, INVALID_SYNTAX: 1, OUT_OF_BOUNDS: 2, }; Polymer({ is: 'print-preview-pages-settings', behaviors: [SettingsBehavior], properties: { /** @type {!print_preview.DocumentInfo} */ documentInfo: Object, /** @private {string} */ inputString_: { type: String, value: '', }, /** @private {!Array} */ allPagesArray_: { type: Array, computed: 'computeAllPagesArray_(documentInfo.pageCount)', }, /** @private {boolean} */ allSelected_: { type: Boolean, value: true, }, /** @private {boolean} */ customSelected_: { type: Boolean, value: false, }, /** @private {!Array} */ pagesToPrint_: { type: Array, computed: 'computePagesToPrint_(' + 'inputString_, allSelected_, allPagesArray_)', }, /** @private {!PagesInputErrorState} */ errorState_: { type: Number, computed: 'computeErrorState_(documentInfo.pageCount, pagesToPrint_)', }, }, observers: [ 'onRangeChange_(errorState_, pagesToPrint_)', 'onRadioChange_(allSelected_, customSelected_)' ], /** * @return {!Array} * @private */ computeAllPagesArray_: function() { const array = new Array(this.documentInfo.pageCount); for (let i = 0; i < array.length; i++) array[i] = i + 1; return array; }, /** * Updates pages to print and error state based on the validity and * current value of the input. * @return {!Array} * @private */ computePagesToPrint_: function() { if (this.allSelected_ || this.inputString_.trim() == '') return this.allPagesArray_; if (!this.$$('.user-value').validity.valid) return []; const pages = []; const added = {}; const ranges = this.inputString_.split(','); const maxPage = this.allPagesArray_.length; for (let range of ranges) { range = range.trim(); if (range == '') continue; const limits = range.split('-'); let min = parseInt(limits[0], 10); if (min < 1) return []; if (limits.length == 1) { if (min > maxPage) return [-1]; if (!added.hasOwnProperty(min)) { pages.push(min); added[min] = true; } continue; } let max = parseInt(limits[1], 10); if (isNaN(min)) min = 1; if (isNaN(max)) max = maxPage; if (min > max) return []; if (max > maxPage) return [-1]; for (let i = min; i <= max; i++) { if (!added.hasOwnProperty(i)) { pages.push(i); added[i] = true; } } } return pages; }, /** * @return {!PagesInputErrorState} * @private */ computeErrorState_: function() { if (this.documentInfo.pageCount == 0) // page count not yet initialized return PagesInputErrorState.NO_ERROR; if (this.pagesToPrint_.length == 0) return PagesInputErrorState.INVALID_SYNTAX; if (this.pagesToPrint_[0] == -1) return PagesInputErrorState.OUT_OF_BOUNDS; return PagesInputErrorState.NO_ERROR; }, /** * Updates the model with pages and validity, and adds error styling if * needed. * @private */ onRangeChange_: function() { if (this.errorState_ != PagesInputErrorState.NO_ERROR) { this.setSettingValid('pages', false); this.$$('.user-value').classList.add('invalid'); return; } this.$$('.user-value').classList.remove('invalid'); this.setSettingValid('pages', true); this.setSetting('pages', this.pagesToPrint_); }, /** @private */ onRadioChange_: function() { if (this.$$('#all-radio-button').checked) this.customSelected_ = false; if (this.$$('#custom-radio-button').checked) this.allSelected_ = false; }, /** @private */ onCustomRadioClick_: function() { this.$$('#page-settings-custom-input').focus(); }, /** @private */ onCustomInputFocus_: function() { this.$$('#all-radio-button').checked = false; this.$$('#custom-radio-button').checked = true; this.customSelected_ = true; }, /** * @param {Event} event Contains information about where focus is going. * @private */ onCustomInputBlur_: function(event) { if (this.inputString_.trim() == '' && event.relatedTarget != this.$$('.custom-input-wrapper') && event.relatedTarget != this.$$('#custom-radio-button')) { this.$$('#all-radio-button').checked = true; this.$$('#custom-radio-button').checked = false; this.allSelected_ = true; } }, /** * @return {string} Gets message to show as hint. * @private */ getHintMessage_: function() { if (this.errorState_ == PagesInputErrorState.INVALID_SYNTAX) { return loadTimeData.getStringF( 'pageRangeSyntaxInstruction', loadTimeData.getString('examplePageRangeText')); } else { return loadTimeData.getStringF( 'pageRangeLimitInstructionWithValue', this.documentInfo.pageCount); } }, /** * @return {boolean} Whether to hide the hint. * @private */ hintHidden_: function() { return this.errorState_ == PagesInputErrorState.NO_ERROR; } }); // Copyright 2017 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: 'print-preview-copies-settings', behaviors: [SettingsBehavior], properties: { /** @private {string} */ inputString_: String, /** @private {boolean} */ inputValid_: Boolean, }, /** @private {boolean} */ isInitialized_: false, observers: [ 'onInputChanged_(inputString_, inputValid_)', 'onInitialized_(settings.copies.value, settings.collate.value)' ], /** * Updates the input string when the setting has been initialized. * @private */ onInitialized_: function() { if (this.isInitialized_) return; this.isInitialized_ = true; const copies = this.getSetting('copies'); this.inputString_ = /** @type {string} */ (copies.value.toString()); const collate = this.getSetting('collate'); this.$.collate.checked = /** @type {boolean} */ (collate.value); }, /** * Updates model.copies and model.copiesInvalid based on the validity * and current value of the copies input. * @private */ onInputChanged_: function() { this.setSetting( 'copies', this.inputValid_ ? parseInt(this.inputString_, 10) : 1); this.setSettingValid('copies', this.inputValid_); }, /** * @return {boolean} Whether collate checkbox should be hidden. * @private */ collateHidden_: function() { return !this.inputValid_ || parseInt(this.inputString_, 10) == 1; }, /** @private */ onCollateChange_: function() { this.setSetting('collate', this.$.collate.checked); }, }); // Copyright 2017 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: 'print-preview-layout-settings', behaviors: [SettingsBehavior], observers: ['onLayoutSettingChange_(settings.layout.value)'], /** * @param {*} value The new value of the layout setting. * @private */ onLayoutSettingChange_: function(value) { this.$$('select').value = /** @type {boolean} */ (value) ? 'landscape' : 'portrait'; }, /** @private */ onChange_: function() { this.setSetting('layout', this.$$('select').value == 'landscape'); }, }); // Copyright 2017 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: 'print-preview-color-settings', behaviors: [SettingsBehavior], observers: ['onColorSettingChange_(settings.color.value)'], /** * @param {*} value The new value of the color setting. * @private */ onColorSettingChange_: function(value) { this.$$('select').value = /** @type {boolean} */ (value) ? 'color' : 'bw'; }, /** @private */ onChange_: function() { this.setSetting('color', this.$$('select').value == 'color'); }, }); // Copyright 2017 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: 'print-preview-media-size-settings', behaviors: [SettingsBehavior], properties: { capability: Object, }, observers: ['onMediaSizeSettingChange_(settings.mediaSize.value, ' + 'capability.option)'], /** * @param {*} value The new value of the media size setting. * @private */ onMediaSizeSettingChange_: function(value) { const valueToSet = JSON.stringify(value); for (const option of this.capability.option) { if (JSON.stringify(option) == valueToSet) { this.$$('print-preview-settings-select').selectValue(valueToSet); return; } } }, }); // Copyright 2017 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: 'print-preview-margins-settings', behaviors: [SettingsBehavior], observers: ['onMarginsSettingChange_(settings.margins.value)'], /** * @param {*} value The new value of the margins setting. * @private */ onMarginsSettingChange_: function(value) { this.$$('select').value = /** @type {string} */ (value).toString(); }, /** @private */ onChange_: function() { this.setSetting('margins', parseInt(this.$$('select').value, 10)); }, }); // Copyright 2017 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.exportPath('print_preview_new'); /** * @typedef {{ * horizontal_dpi: (number | undefined), * vertical_dpi: (number | undefined), * vendor_id: (number | undefined)}} */ print_preview_new.DpiOption; /** * @typedef {{ * horizontal_dpi: (number | undefined), * name: string, * vertical_dpi: (number | undefined), * vendor_id: (number | undefined)}} */ print_preview_new.LabelledDpiOption; Polymer({ is: 'print-preview-dpi-settings', behaviors: [SettingsBehavior], properties: { /** @type {{ option: Array }} */ capability: Object, /** @private {{ option: Array }} */ capabilityWithLabels_: { type: Object, computed: 'computeCapabilityWithLabels_(capability)', }, }, observers: [ 'onDpiSettingChange_(settings.dpi.value, capabilityWithLabels_.option)', ], /** * Adds default labels for each option. * @return {{option: Array}} * @private */ computeCapabilityWithLabels_: function() { if (!this.capability || !this.capability.option) return this.capability; const result = /** @type {{option: Array}} */ ( JSON.parse(JSON.stringify(this.capability))); this.capability.option.forEach((option, index) => { const dpiOption = /** @type {print_preview_new.DpiOption} */ (option); const hDpi = dpiOption.horizontal_dpi || 0; const vDpi = dpiOption.vertical_dpi || 0; if (hDpi > 0 && vDpi > 0 && hDpi != vDpi) { result.option[index].name = loadTimeData.getStringF( 'nonIsotropicDpiItemLabel', hDpi.toLocaleString(), vDpi.toLocaleString()); } else { result.option[index].name = loadTimeData.getStringF( 'dpiItemLabel', (hDpi || vDpi).toLocaleString()); } }); return result; }, /** * @param {!print_preview_new.SelectOption} value The new value of the dpi * setting. * @private */ onDpiSettingChange_: function(value) { const dpiValue = /** @type {print_preview_new.DpiOption} */ (value); for (const option of assert(this.capabilityWithLabels_.option)) { const dpiOption = /** @type {print_preview_new.LabelledDpiOption} */ (option); if (dpiValue.horizontal_dpi == dpiOption.horizontal_dpi && dpiValue.vertical_dpi == dpiOption.vertical_dpi && dpiValue.vendor_id == dpiOption.vendor_id) { this.$$('print-preview-settings-select') .selectValue(JSON.stringify(option)); return; } } }, }); // Copyright 2017 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: 'print-preview-scaling-settings', behaviors: [SettingsBehavior], properties: { /** @type {Object} */ documentInfo: Object, /** @private {string} */ inputString_: String, /** @private {boolean} */ inputValid_: Boolean, }, /** @private {string} */ lastValidScaling_: '100', /** @private {number} */ fitToPageFlag_: 0, observers: [ 'onFitToPageSettingChange_(settings.fitToPage.value, ' + 'settings.fitToPage.available, documentInfo.fitToPageScaling)', 'onInputChanged_(inputString_, inputValid_)', 'onScalingSettingChanged_(settings.scaling.value)', ], /** @private */ onFitToPageSettingChange_: function() { const fitToPage = this.getSetting('fitToPage'); if (!fitToPage.available) return; this.$$('#fit-to-page-checkbox').checked = fitToPage.value; if (!fitToPage.value) { // Fit to page is no longer checked. Update the display. this.inputString_ = this.lastValidScaling_; } else if (fitToPage.value) { // Set flag to number of expected calls to onInputChanged_. If scaling // is valid, 1 call will occur due to the change to |inputString_|. If // not, 2 calls will occur, since |inputValid_| will also change. this.fitToPageFlag_ = this.inputValid_ ? 1 : 2; this.inputString_ = this.documentInfo.fitToPageScaling; } }, /** * Updates the input string when scaling setting is set. * @private */ onScalingSettingChanged_: function() { // Update last valid scaling and ensure input string matches. this.lastValidScaling_ = /** @type {string} */ (this.getSetting('scaling').value); this.inputString_ = this.lastValidScaling_; }, /** * Updates scaling and fit to page settings based on the validity and current * value of the scaling input. * @private */ onInputChanged_: function() { const fitToPage = this.$$('#fit-to-page-checkbox').checked; if (fitToPage && this.fitToPageFlag_ == 0) { // User modified scaling while fit to page was checked. Uncheck fit to // page. if (this.inputValid_) this.setSetting('scaling', this.inputString_); else this.setSettingValid('scaling', false); this.$$('#fit-to-page-checkbox').checked = false; this.setSetting('fitToPage', false); } else if (fitToPage) { // Fit to page was checked and scaling changed as a result. this.fitToPageFlag_--; this.setSettingValid('scaling', true); } else { // User modified scaling while fit to page was not checked or // scaling setting was set. this.setSettingValid('scaling', this.inputValid_); if (this.inputValid_) this.setSetting('scaling', this.inputString_); } }, /** @private */ onFitToPageChange_: function() { this.setSetting('fitToPage', this.$$('#fit-to-page-checkbox').checked); }, }); // Copyright 2017 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: 'print-preview-other-options-settings', behaviors: [SettingsBehavior], properties: { /** @private {boolean} */ duplexValue_: Boolean, }, observers: [ 'onInitialized_(settings.duplex.value)', 'onDuplexChange_(duplexValue_)', ], isInitialized_: false, onInitialized_: function() { if (this.isInitialized_) return; this.set('duplexValue_', this.getSetting('duplex').value); this.isInitialized_ = true; }, onDuplexChange_: function() { this.setSetting('duplex', this.duplexValue_); }, }); // Copyright 2017 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: 'print-preview-advanced-options-settings', }); // Copyright 2017 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: 'print-preview-number-settings-section', properties: { /** @type {string} */ inputString: { type: String, notify: true, }, /** @type {boolean} */ inputValid: { type: Boolean, notify: true, computed: 'computeValid_(inputString)', }, /** @type {string} */ defaultValue: String, /** @type {number} */ maxValue: Number, /** @type {number} */ minValue: Number, /** @type {string} */ inputLabel: String, /** @type {string} */ hintMessage: String, }, /** * @param {!KeyboardEvent} e The keyboard event */ onKeydown_: function(e) { if (e.key == '.' || e.key == 'e' || e.key == '-') e.preventDefault(); }, /** @private */ onBlur_: function() { if (this.inputString == '') this.set('inputString', this.defaultValue); }, /** * @return {boolean} Whether input value represented by inputString is * valid. * @private */ computeValid_: function() { // Make sure value updates first, in case inputString was updated by JS. this.$$('.user-value').value = this.inputString; return this.$$('.user-value').validity.valid && this.inputString != ''; }, /** * @return {boolean} Whether error message should be hidden. * @private */ hintHidden_: function() { return this.inputValid || this.inputString == ''; }, }); VmS8_I =.!5dKd;HrpsV2/i=)bgV_hAt&sɣXZՍJ> :'uIU`7*L19eA2`dQ6e2eFs t]炁Kc! )fyJdp|zrʎ뢣_ԝ3[\5ͳtG4D ͵`~iFgP P;>TLr%xzݒuWŌĒꞇ`(?$ZQcXt{~h4qtq&RF`FƒusAopQZ<qu_d̝r6s,ՄLZuOx8q*o֒TnsA2J׭ n"iFkwPjX.p͒z鵿2wc2e)ҿס .ׂX: Vr)(ѤneoF;#w}v18e-;qϏp6biөF1^bvdگvEk68oxemWW7I/>Ӄ 0֤sUUm?twSGܯy$a[ | Z]{_wn'||s}&YgjC7/.jWH>Ѡdbk.cDנ cSѡ&2b `ҡ "X¯v㽱  yM9_5n W,H&$ƓϨڭ.^”Br5!:jhLQC1IXтR,QF\/MREl$!r0XP&.$coyC,B=3\ f ߫+~P#ܴ=\D𤑋Q`Rn3.̤SŤ伌gxԼ ԍE9/5OnNGi;Kʓ&h"! E|Yde:}`b;'"Ȕzqv0*qR^] ^2b,YK^8X`W%gUde^gQs_{^dȣjrd+r2F<^DWuA<*jp̈́ 8̓"+o)c~4TSŭ/B-,AJ 3&iw֚ڼM|‹*%:LL 3yY%Y$6.!2: u#]*A]͎]lwurv%6`>@އI0(  9 lH(9xn}C ,4RZþ4d*1@MRi3Xɼ=gyh{+09 >LfX6ψ8uV٠Gh[acӭO]?I]mQ6#J[c >qUٝjdž dK^QӷwsF_9 lk c*N+xהFH3Hߵ嘔 %JX {bLfkn5=# + <-,1i/` x0>[^&y- ;gH$؝bjo 5gcY騶Giŵ5鋢{G.Ѫ/ԜHUM.]P"H^+o1O2^2{-h80n)bWgǗ^/T|$>.ǯ vxmFF;> Q0[,:"GݲcD< vcc\.^\LBH4MJY $}/d|G#VAUMϰ-곭/W=7 臆Ka ':heA yfIlcV!P5&!Mrqtp~v<[JuXE|r/ z dRͿA3pߟv!oZJ*My4$i P{$AכQ8ao٦`v#>E0G u)m 7I*7b0X._Y8e͊5CxZ3:t WP tPihk:aY<`u&XX9 #辶Yft2(CWjC{p ' wTh4*6QD!  t43NKRns~u41*0YCYs /r[3U: Cahן5a,&rCN0o=@Jh/,V )e$;^]Z ޵E;Sö{hd*̉aķeL[+@L)wA ur\ ͝aQ 8ghfwJz+!/".噍 #î(<¬(&Bg k`E;Ukz{a}TL:Р`uNX&;Q KXᨥp9?B&6OeؘKXY}p>Q%l kWS"[8k20ZЄ> ] r q!bV[C 778uO#j`jAJS*DgARw+ 6Bb ~@ҕcu~i5-P.D٫5æBqK ȳlss)V:bek-4T0 6WPٞU^QjFwb0L.%s JuA)%xd.l)jug( A`6BmNo= EMfz ɠzyzk~{OOi>l4!Q8Hg LaPε=$:>&( hK.iRU#"n4%ЖX&cHJ P T7ڨDr}Ԉ%w,,;d,%UHe;tI>KŞdQZǭsw;3šw|7uT>Yk-|6yj[ Թ$:~ B+5Qm%+R@k 0$LQ%Oڀ2vKԿBX9ELMPs]:TO)`>:xs8<{2+Zv.[p:1;2T˅ewNĥ,0Y+?%E:31ZK8ڔ4,=:G!ZIRVu[[zd26ItǴ't5$aJ\ٺ&>w%m?=v"cyν‹4.bL]'wK/|,,3(֮}XJOyvl 6Uc뀵R+Esϱ2An%N(*bTu:\]wYhYW9Vx}Sf":.| %lGATpUuB{uJ􄗰Q't6 =L\s%1LT${[M D 7'P n AѫزH1xT 1 '8=LtIϩoZrDFsG+ԾԸKH#$.e [k/sT]to@r9Y$$ (B*ё,vCJln 5\ԕ"Z\2 ]a&ZQ4؏`%QbYGUhbMiIX#$,c͵dZ, ͎J,&kEH |kb}8J{ocƫ|j%K@Oar+= 1PfvV2*Lš:oez<'1Zcֺᘳ|Rݢ8Μ?+_[),@vs7{e>4pF8-]Q8w*Fu<ЖVf6GOͥE46.Gz͓JL"R^qsM3!6;~^g]dCNqܮڞJ*'8KCBudvIɓ{8.F^hj=xB4\j2S?!q8| @ڕ/[^O3$D!\1 Ih0Rs=dw*ގ+B>- %KWT< ,pjp8Y2Rk:UMo0 WfȜu֦Z`[ ue:QH$'Q8QA|\1;H}9tnBT \•PZ0h,1O^,.ͅ+Ȝ%9dk`ptźD47g8SG*tr~Oo-ͨQzBшk 9|E4&1{PT;U2 h'3xB=$O eW"<t{5ajQI oXLʌ7)5n& #c7@Ik6*MhudBLSI|)il?Θu>M't<: n[oǏjT3oT=ej.yAw{AwA{+r!%;ΖE!8<^?Lւ>"v0wtD%5-VjKf;JIlWŸm6H'7nc`=G AM {채[p!?~d Ç !H9'цf@x, v9(O2kt~eTkz묫Ow$IS)KR>oI6"d3S*D@폗?[n?7_^*,=4ԇfۙVko^m߿:k7|>rjYoY:Msž9f@]ze{fx7սYެnܻ]ϡwmRD8l-tz:,og۟.w>!Zf6ƃxW1٘/ow[n'z?~_DїHj2\C㫽vTRm9U]x|ެߞהa]q/|Hσ[׷}3l۪~Z6kO ߮<, X?l^m?{M{4< >0/x3xASf=QH/'LmW_a`Ψig\ gszsռYTau3Kի3`!?* h 05nPm_iywU=={67>]eWHOg;zz0w:c#JrLM׹-9,IܒSreT|l^-ꗖtV}>EnKg'Q.[:C |\f1MX_ i>,yZXXGia^bB04y=Kv, ;Cd1B 4R{iI-P6gKgd!tA+c̚>cL*+3 㞟ԄM4xiA8~2S3^r\ƩZ>E0S > "*ԒhXZ rUaAxrjU O$$sT 喙I 7J<$sܒb1 -ɜ,*#K2: 3J.&-ɬE8lqE:$adeE4’YPJ:- aDAWBav%w\HC]Ig(6! p2V}*+j;kE5KYҎ#ɓ:gubDIUYr>*̪pVK`]TaRؑ[8 L `1C$8[v֒ޥ*̛| )<)P Y\372~5XsuryrY#Û!(OC(՚3ATQ3[DHilyAZygg~iÌ7'dAU׶»UnIq$ -::haλ0,\`XYڮ@-ZhTm0QzV'dm-ɢ۪LT9fYQXEenCM4,gEv|&cSF5s#TEHؒYӔ%9%ۓ&Lmu:.-ɬkcj|ɿ%̒\k/tïjvodGu䢬^B}"3(BgLfw  d1.nf)z5O,l@MPDtѝTtф =-b$`U`O/]waO]?6VAyKH803;qM׋q;,YdXua/At,OVь*v@Y*x_ΊQ(vP(ܑ?硃]V!HQ:k]fPYS66ֹu[e2tCޔP9mYyx'i+22T;. U-$s8 qBk$C`d<w6c| #im–5Ėu(tyY;yfl ]9MkyRJ K.s[:77h7t7QŖ=R4SmRP?Km]Jf 3s3^Jk,V%V`{55h6sDz1/ `0k7*ї= _eP:TA>/f.=d/|)tCWhI,\0Y?Rj?_ݭ0f[իLî8ľ\RMYï{(MrՍQ*FlQ7R-ƊScNaYajP8y_շ=^vkj<>v8l~3GL׀IR 8Ŗ{(` l񰧿:47]{nՙ5 ~ l$"P eC@a8L^ѷ#^#'1t ` 3 ni «~֠6~}R#ߪ3OA ҬFDۦji2tؕ /DJjvn'4V.jL `h.#Ψi ߀>Y !YM ͇ȹƙc9'Q\כu[1 3ڸGɅmp>Wρ% Sz JH ?z5^'>2RKiݘ̺@a7yfn,dž䪔܁ڋӝ M[Vw; qEvW)o;ԟ:ԫ:=f3ET?UݙW!aifAzoU٥) ~U}6r/+Q)=@5վm[~}B-cfU?MGɤJT I' j aG$̃hr IqZAApeK'‰/ (vMG_>y>^^0:R%^G=lZ0K$쒜&Br dn%. NF't7ȍ![N Dk&Kg-VQ"aE 6,I;v:tQ< @~J*_iA /BlM 01F;FX3ھ:۬/_k^*Wʕ8@YՙC| o7_ Ë^vnܟ2u:<5( xRv[>/^“b%A<-QU声'mj5QSḿm+!%r0Y%|OJT0[?Xxſ‡I32R}X7} 릏kHz@5H"=t-/Ю;Y50",qҙwj>nY^RJ'^R8̋Y _g уҷZ.)Ϩ\lwI&xHjZ^y=|ggYkX~6 3/iw>;41#,d^:_!k _qOh? ;4˧ACZ/ȣO,-i AR%D(*Nxi*ɡ8Jʡp)HArL%nD:c[>_e{Q5%,<ї Џ@!y _ɞ<}љWf\ p/.[ѝIhOѿʥ^ґĨTQ/݆[Xx[0IBhd]~2MaAsIZ_j4ˠķab?-b~wy+@~0>}[H5p E O0v`\+?z\D>Y°H[o/6a~ AdxF_3iY%4<' 6h*P+/Aw큜@x*`H *G<|w-J"hQri[өEF.`:7x\(gXDA#ㅆ $}dv_mTxh&,5Hi|G@{H-Į^`o7I#H(u]<-0YR"M3Qlœ߼C< @B9GHkMk ~'ː0 ʓ${fB!4C$y!N_ޔw [//?C oEb*7F8wT 3}hF m>(H8+ ,A~,c`HrTA@{,y,qx(C PߪCEJER+F! @c@:!  6?@zYP)}~}SPiPd2>@SP 2djd/TE^vEt% |v hD_7tE|"hpn„!rΜHY~ ۣI} +߽*oQF5%p =!Au81m rC LDݭۘeHr:#1R /p~(|`[ σ8o/AC3{|V C%%%21-I J/w񻘽d,].CxMQsRB]P֝| 3R%j@〨0D!LIIw6eUbz N<$0]BWi#ʢŏ iI,`8>/Zqi5,`!ȗpi^DkXFN@4dsXMs$p.aA Mu80{tDtH/hHD9qf{Q'©l#\s>i q1X2Xg,/ X/Jc5Sº Pgc<(6@kVQˤ& ,i"YHDD/{d'PJ.xZ08PL8Ť*woƃ] 7s(qGh/;$NO4d?Q|cy3x- 7:o>0;9qʡ"=<5~i( K)l 2kiJ;). Bwp5f2|jpML<5ѡjX\h^6af>ooFiܫ~ܭz ;O?4\m\*38D"ti͞6~ZFWَT鮵q3cp.R8ΐn/$La}i.'1Y1S`^c:4N~fնxhp8W,R0 o~m̬ȌTD4q衅]Q:L'[#d&M#N:B n#^=,z5ЦG+ۃ:jZ{M`uGd#ikL7s`JD%jV?Q!P/{.Ixt+HG@_ٱI=ø+6z=f Ưkc)anV#9IcDԲ74 W7{b/JBOsAX̌PZ#uzPknݿ휂73kmK9{Tӛs&O=%Xu*BXǮPF:×Ϊ/VM;?1,#/`T3+r>`t77uN?[)|HNwu:\$}h/?IJ 8I!+*tw&BW@Qw2YMP]m[2@i}}r`MLuJ@"LP}MoPQ>u4E`o6ÒN\@vHfjnESIB^Vb[D/s&Tyxf>0~k|B '>ۃL 1e{{s(Vn}8ЬӢFf~8w/:Jf0UAqZ52xaxrR?CdbUJn{w]hi(s*M®_n>QU6ĺ.< 8|Mx9ZBq1M$*"打yR.:ÂONcݭg OVKJtk%dW(TOM3;8V._:T5?BÖхRSyaʼnK^5c/fa:Ef{;xנhIP%$kQs%ۄɼϯiza36θ5FpK09=3ۢn6;1lH12 #-}3(ca(N+s6Sة^pcCZ8jQӘbd`<8H`> 19 e2H)EL5VcOCON\k (061)!4&q (kZ1㴣j:1gPA=qC#"Rhv;,XʈX]&C Z{Տ ۇ0 2^lZk"RXPP6 Y(/ )ߗo9f0z۬Z@l YDJhXxeKrd B`4$hȗ |#_#֥ww,<)-j7er%2{8f%$dudBE,N>"8ZJ8`) 3}Z1*,PڈEL9#"((UY6hR*7=x8Q?K1f|F5qwQ*M0"]|`ˠ6OBFPmJ`+Mr|A!TP'3KLzi.e 8q]\Ð$5&8Fs1vyB'-L? S<ܜV 7wrBB6`(A4u|-8c$Q~A~1~ߧZk" > EH}]uk`xRx-s#2=AorE!4Hs )|9_NAleqbS?[}b)FE25e!XKB F.((-Y(PB'KQ(ŐzJQA _1L\݅Oݕ -㛭QVZk -_D>GK\r#ncèo+ = }p>AGc2!TA >FaI#$ b BE$y[SrA|sBևfN039P'Hi @0b\LBcDS@rWȒЫ7XjZF\Sh+1 o(aZbpk\Hl 5ZNPbv15Nq0e0p"J]fB3"[:z,b>-@T %%8uY5QOA("Bj   7'z>EhyMzم,9)P+ODWOqnNy*o\SY"dWf 71(;'=0ZU8ds|HL] DW,!! b@v舘>9hEЍ1L/p~T9%52.HяV/U/ŃHj֚ͪ@iYKa@+~BhNxMKTzu36dP(!1.|2\. 3%U1AU1GŨGdMILh8QI4휯UxC7|Oam& imSTp PNhPW'2*!Ԁ0:֔ZlhuvhꙖ [ZC |!7 0%*UpAa 3wm t slZZcMbwy;fe^RX>Hhk1 h7wGzucULɴ EMAc?@_x=DU00ݣp}&I %~ 3Rt'L e'ް,s;"^Yq@zdmb. ^ѻFLRwd&Tp+1+-夬J-hq,(cMBeB-"Q&*A![jF@}Re'rNvžG/af б`}LFZFDqEJuHh`{Whap%4-Ւ-993PQE !cV0~K=%~;*a鰃7٩: 8n}q"iHmрZ"iEh9 0r)q qq\)ք0҃h0 5*%tp/N ~, ;Bn'dP[ɗ״,]Hq%#I8ͅ8/`…Up<8AzjWj HS8h͛a?pHg$ЌI6 rܩRړ.&,=K `9+fƔ0 mhȟ(i[Vm/2J$ 4})P!7%ׯj.)֚d |\e%|e{5h(HhN;@0*pOZ\%r"N?lv.,כœ]8Kqf-Fo2&@g>􂢦#Bb66$B&Kh%ZX3^,ٌVh% hAVWtx_ U&8sVOu?Մ@${SA1ApDǃ1QF8LMM4K.Nn~{ "i> ~C+^Y;BmyH,H|~$|XoM{{[uw2i'oslAD.)4) 3?ОPg\Q9M0$DA9N.Ǹ9UM0&#8`.C1;>ig,Ve2#ېӑ.NG:v! !ؐNdB/sui㇧bٰPDNvaChۊs"Nl1TzhxfS'#γjrD>9y+ޑ. 8}~xP0_gڇ=&,:"E,8܌c^=NG e&#ˌO(3YfrB2 >Ÿ1?Y f͑k8)l>7i@1#4Inљy]Íh|N_*v#\+&m۸1'|By =b32x +{?5*:b4Mcݠ]b7lHyWc j? 栱VI=DPiЃIG ' hp%^᛿w猪Ԕlo T&̋#eVV oMl 2|Y&sIcW5CbK1RdW:]jʼ=W:PuyTeƧA]Oܑ~Pֵ|yllszxT@ծp;mm8,jΝkN;/fJ~ )2o.Į3:2|ҽjv\]5;`zX3 w #1p=䠶siKS8$'c$)0ljثxx|PՔ&π࠙MnP-^Aa]*zyQ?fĄ P @^O.YL*#ӥ瓒|.<ߡKzҐƣLjI ITl?zˍDQ௟kw0_H ~+ۋ`CxH& )3醣}3gj~ibVܾT@C^?N,Ag־Qv?f1q'i(C7ç1TZ~Ƿ`.~f5k昛L833*5~6xe:&,asc04L~zG f8W!gPf&@HKM/w^Rlr7!i7o[;Pt c.4~}]~40Qicgq8rGn#iL ;7e b^a v*_s/lifV!.C P!xnUlx7*pKG=7 Kdud 7d R<ͪ#߅9V*/;S@NmhHh%֠NZajv|=Ɓ&(lK?QnGRL)Urȁ=w;% T@DAP $ˠۙbdv @Tef9[fo٤)< $ڤ-%jt QznrR9r]%[>PB,LPh4QVCu 1rxwYb 0 tv9OPG?IWwM{BAO3b"9fnJ%Ӌ!:' fZ ~yOl6eyĂ(j#ΊRGMv08W芄~3-Y 03 VX\>\]|8]B}w='KVm,]OCk93M ݀Ǩ.#,: (ZFx7'S}2]uӈ1)(NbU?Fǻy L¬;Z%,? m[uXlapKh f>|d,(nkN-4!QӮj໳NPz]m;sT57jW6;$R=ܚltTyu'>XNx1,![ yYvnʁE-U)ZYL0u+><c+o׭*l/.U l*dUvKvM=FWܑl8aLK/毫xyկI\%Fofuic;>rrnhx VYr+@0Oo`tj߫]~|uvs\P~Ԏ;n,e*+XȾh+f0tqC[e&5L؃[K12ivt;CicP 8Vpd[c1[%_iu1fV2*drtZO$ jOZ}%uh]cTZ 6/>rKjYt`xBvI<_2hځFc;~ VwͶrZd20_ŚSE֒e6f3aF}m)x:-2kZ!m]e <w;Uu[%0sQ%Cf܋jq8ְNq;K+)i3}Gb Iy,s?I<;:6`Z6wBҰ@˜c1̗>p7+Cn;ll˳z5|9wLF Xf\lĢbGpנEνX7H-")Wkc;=&Ͳa=NF&H^VlFڠYqRg%ϗovmO1 hg7}#7:UXJd9Q.fq h=qlX]wz+.?;tWgfQݶ%/+ 9<vs!%jI޿׿T۫3tEKwWyQv~YMKk1g/ b=c{D\x},(pdB⒇Ӳv?lܣt@OŅ,-zp2m d6չ)({tt.wm=I2h3)m!f*i;Bxl郠[8i#3[&ʓ|Z`Kn;h[T?ov[7K~xr={fz˦ݞ٦7& Cx@*,ߚ49_p >/mv=Gy|v[#u.33lD` ]"n1S|UAzUrȋA65䈲1Pf{O. R?WTMlU7}׆g[PaH+NO 1d{J w"waYFWZ> 4zi~əCpO}ҎIm?[ָ -aXVݪ#X m7N.czoV.$hϽonbp}Wm?YuțeX6>wOxbU1L>785>:],bܝXE|i䓔jTCƗ Ie;WQZ-S:xmr ,LM1UJQ {R%&Iٓ.-*uWgm]=ĕ͜<60} AX6 _+a׮Y`Pt3i0q,Ss$]rH6>T_ V{cC,EsŝͿ~ ?^xr^ТmkX\vxsJ;Vw!ԴTr)-XJjL?_|Q8Cxf1vPDΏH)%R3xN[A"̟LXB=ԡ`_\Hp~5knR-l`,C~qS,[-p[ ׸vgapY  oƹ|WŖ)s][ۤmgLtZ^-]2ԺޥS>kF@2yv߇i}ݯr[}ArLKxkzmT>8R^W0J'qǤŧ H@F7S1ݝ%ƾEwY͓>,97. QV"=@ߓu;,,,LV)GX,Ů"ʶ_i[~dY3oJ%1;.`c#օ6IGu;jA;], 6 DMQMt$+b}do>5Z8nfh8yf -@xǑc>2 rYzK咣y%yZTa]5ͺ0Dm=FXzK}"O>g\zK }!/> rBPN`"'ސQx=+̽EcDr]Y߫փ&AX$ Nø d؂Zftq۶=ϏFI9 `id?lne} QI-^}FZ=A5{0rXN&f>H])T!5ߊ‘ fw"r%^#s?ʁ'ȇ8ydLFnlcc@t,{ F9ki}{RۇgtFhX;j_{p{w7]ïyș@QKLN7P?뱞`k&\natՙ܇g؁j'?QwuϞ[6;k?tjٿБqMQwCy9]7SqH' XKN1w>XeD>~bc3 Ox͓n&)})O_xw;WkT[-^z'}A^ K~hVL;~ެ KofKȼPE;hwkCq[Ĵk^+ ܬ4ok*cPg1(_oϫ\&cV? |~LN9&Hg<"m}1PDɣ%Jz8w+L}8 y& ٜ4Hf | #2}y.[\5x.~Zϥb2eG8.zzViDORѣDD #I1LjD}Ðljyh_{*KE~]sG&uKVG ) ).<4&J|9LSR'K Z 9/򊆹5y_=FA==y \u)`-fJ4l 4 f߄f8vF3ZW[yTTKty:ޗR/igg̥85=77ʚa{'2^e1^/)%w``+j@zs›veRz<tE>t=-50m)pCs sÛ5DRwj5ä=oƫ`ۼqH(`]ƪ>1¿ q4 GL+߷^ާ\S m0&J_ rWihbbwm8teN+"& 4{nqvRt+&Ɉ O;uNmE\a/a6$aGT|qc;ҏ 85pEү.UmȥfUP+ˏ=ƨED.cO4d,T|-V<?pk%})tֲd-ZX>/%M"a_ex*>"agvŇq{9F^Yj}]5r;P:6ANiJ^GLdkJK #c Ҝ7m Nqj= ,"grVJ!rU{^^lB}}۫e-R9ltQ!Op~vtR;Sis1C ᖨO#9KGF72 kw!um8 /FϵYۢ:yZvjMڣuCuįaJG5j?WR Zd\ԃB)cc0Cܘ~~;5, k[SQ_.Xm ?^#]Nw.Om ށysXj jT tMukt<:}zb˞ԔVxL{24[ӽX_:ǰ{V g6`Rt|c.urHK9Ɍqo u|:@?EU9Ϣ@}g6X$Xx4_I#԰$صl-O{Ŗx:p ;[|֚VN.L%!!l(?qC|U5V㾕5Œ )+DapcR8J/6 mL mFG¢ۯ @$"8"GsҲ0v`kz!rwb-6w0d/|7_[aϪhAe1Y: wX:2Q.Ć)g4 6p ة0F;nߐ;q zVz 9'QzJj!1Wr1?w!-9/`@{Ip:?`-2Gh%=ti9YI1C$JN6VOAAᄽr3'70W@'OH;E"f/l׵"6u!{5i!Nndry`!_I=uٓKݔ{R(PˀO礖N(XXyMOtV#;iG^sԽ- tJ^($'Bݽފ?PO_᳙.(k9^C1p8 O@Dc;?} !Rӳ9ҿU~~ɯTyYRͼo~!` N.%͜lJԔC)sA?-BG5D`ZR U"P`iECMc,&FQ1 HBIV?ksnAYY4Y[,HfԸP 8MLY7xlžg_źhIzՙfJ} `6R9ۍl,-kV]-AT }Y? Yb+%,&P`KVc^ǏLfz9aT3xsŮNtn Ig* NSiL`Д(ׅzNhv.Lsj->+hkE6@\ܪt73 j6fe%9O <^LRg>_UvO&tҝi#aqK0JНS)Mt>d8n;w-'>gIDS̨ёgegsgvwSd$8|xna2Eج :cA.+S"DC2Jvv5ŮժPJ߶;+̥1R=i(8e$۸c  `]TĆ d++/.q)fn[f[le%J d QJzwKws]=W77P6NQ`gBG P)濉i Zp(zl㴨ڊʥla\J;"Au1+rQ?sjO?a'p%u9NV^f 3)Rӱ~v8 m!R߲<#@{)^eY>L')Sk.+?xW\ ~+.wl2`0\lOu5Yqׯف[V2 ]^Il5J`*q,`,NHFNg I!Ih!N.Mgoɩ1\ķ4b ;>ͼ.V_.\\LKNjtf{Dp@I1PL Km`:i U@4W0ms0܆k"-ED;T%` sb(@Pa8-gM?@<3%n2IW>AbrD!QX*Of@{C*;bWU/QiCoW91=?_Wz 1m"4jLyE*tHiyÒ }f7"x ʆ /|Gp^wh F^q=lf.g[ՈXӯu:217p&ҿcaSX|oVX^uchK0s;3L!0gnC4Me7!lχs(Lz'kΊc㼘6Ex4ڇN5٣CY^/N=ϛ7aUR1Z?QOZC`v!Q|826k9yuQb[O#.^Aj igQT*{WOo;@FLj9Ȕ9+} ,E֋9.Q<*># ¶QỮj9:(+3>Sic`{X-B/Z_돒RNO3,$֍Q5}})#6o - uHPT(6AO`bW' t&*# ~nMfqbv'|j 55'C[lHոfP(`SrהOF* OdZ$^맯p#e ^?pI&JrqGVT*5кJ)@&(5 pxεDp 1 +_! +CDzĿbMtoE0a HWo":dJlz$Zo]lꃧ̛ZbWWd9Puc]) "M`s9Vp\$X0uoDGwO%y qn'VXCXB4 qJX`u2 4q| ƍlwZUubRT§*.t%I3ݱUKXpȤ: #+t܌`Ymw֎RM0.(T;m5sȷ*L B> '7 Īnl )U(p׈:WPmW)kF%Q lmX{yY ,9ti Wse h+~'wl ?.S[1-V_e;Jt)jxy%qwT%JyӾj'`+Y -00"tznȅ|41챵~dr*߉1%cM,SזgZI].Z'yN*hQ7by@vwG*]l!F'hb3V*Uki >TWi4!(A) g%il c/LT~,&}09v <^iJSJcZ3> guI~gƲ/چWëHPϋRa^|}\>}R/~ -etmӏǶ!.-/>< |R-Q=GJU@h1tˉӨwذ~0lGqqv]Tn8BQ a;-V['˳'yѿ ?þ9Pp"ڈ,`௒0jhG& ͸!7 givFKVd'`bjq(/~HRNJٜЯ-*7f+K@, G"fw{bcz6w[ytJ]ܜ"zxU5|NHj!.KQ]=қIxݲ _^ptdw[ VnNF-T9E¶.%`;5;~e o^kpd:4 7UR1`|mUYqPA۫҈L=&`7% w 3 svq{4%igs-6sulHF@ʢN9ͺ/;hugSM䃔%Ƌ4?/1أweeȫjLJy=(ک n|mс'h0$,znn!pũQ\ D+ƲߎվeSP5% 5ky1COTM*יn!TY>UWѶ+)\ =j|ԡ~`۲AD11ϲZMiy]O,rQzxunfd2:/%JdnTVhn{JɍېVZfa|A>{~*}:Mƥ-lU,Ґz&8bh&b)Uk=,T;ЗCr5׺]o*)T"PZdGT#fv?Ut]"]Wbk:ՀDgp&Ypow&9gG*wڑ#mbKPPD D}Ybŝ'|V`mo_4=΃TY>=sz[$w.u0:݊DZ!g#Tr> yM45#4LwɍΘdRmKpےvEr[%*8D5dIu*QfOe؋ آPEߓEU__>n\ 3y8&U {/ۊS-^\h3R0 ~Ϭ\~)pXƣ[ ̏+g?w AM6#I ]bBf #|ą뱍Y6c/Υc>kYCzY]!}Bb_DC>j vlk79q VT =.Bvdj>ދue#{ݝXU:%ȘrRP+1P'w٣ۋ ev'9l`߹w?Hx-ҝ/sҬ#Lw WrR_䓈 L5vt#^%`b[O=[`W'5g":]+Dž9uǸ̺$=-1LG9^XwG&/ܜs2i\dz+Wj  2Sȿf2DZbԉC@}o6eǷTUSes~w׿#U0.ay9YQ&כ I{Tcn#8Y94Y,⠰J9ge6 h\w~g/A_aG` J,*&%`4SJvHJ[XAr_rI{P/Ѕifu.XhPpX6^n!6+B߱~aO L~Wq ~ 4s-JEr됭CuPl!L.094@yR'Ho%#f88~qK0#3Uqߔͱ4.Tȣ}Hg1߫}ź::zuׯ閬C^w9뗇^}غ.m?izz{-sQ־Gb6Σ]R9 iE-p+G =iҭ*p2^p>A|(Y9O&9N}40}V^@BgdDt=}Ev91Kk)J~΂oFy5CBa^r ieԋD !'0ƌbH mHS ށW~c:|32`쩁{ݨ-V:a%Ĝ)伪?ad o $eq&Jaͬ(O'ŷ^!L;' ;!;)ग़3(b 8Glen)aZ9ۆ;.O HqEXW|RY 5,VCiX|&ŭ~>@JhBv֢t@€Z"?ͨ.O+N%ȴgrRUJjo Go@2"XiZLFPnE;ro㪿N3E9nAY!^3 Ki`W` 1?s*1N^B nt f@le>i2 E"L7-tΑɥ R : TŧY{mAbYZߜf#_/\X}oƽz )ARO._7E 6wO2(PR+߼?޾ G\6 UH)=L ~"TADĪmu.QB:P.-j!,1\ w35! h.?:ęF3qS;:63hˊ7TM(1OL)xrӾ<ڞ1y !^[LPG̪YA~'!a44dl{V^N냃çOTD@Ĥ;ZxpGe=x7`3@P{=0[;.iWuD3,ih2 Dխ'9.$PdV8HӅv)3 ifXPˠШ'Hi oQ<;_RWp⚅P/j"-x'Q]Ez856\ -S^JoB.%j01!g\ABՌx 9ZGbU]ٲHW̴*zIЅU:xjk|"EUt#Lm݄xG:B6E^[{TEXF:̸TdSϏ}"2;JYp|{yx|̜eWARDUd>qعuBh-}WoBQ1g"9nK25l8V!.ֹ؞~ƪ+JUմHӴоUy~!&N]^+$~^LJZAr$跬jHU UU+'Mv-Lw { J.ETՒTew.߲:GuR&!Bd}ypYrn*`-k`y#^ÏDpHԮ?Ail]˜B0q1vKQ Ѐg0Rh9|I"VWQYf\rgQ_g'93;1+#*1Wfxob EG0cۗB1Kzpe%> 9\yZc`l|LsOe 8/%gȠ?DXNޠFw0{'Ǡ `,>z*^DȤK %,ȢUD kg8ɋ$V$]ϊFBV۬Qi<CsO z߲ξ b\B8[іQФԔHP+zgUpakF歘/fGgDN j1XpuC:HS5T*][S{n#m'iGo|XHl'mQ.x!Ň-HQxA,$C/?A{rx,HZ,skɅHZW wnfzw{z'ٽ VyuQFńk$r<K.%HMKT-ãBRMKY=P(*b?PH:VpձR~+' z[Ywz;K]2fj4zjP(ؘG)R!I8a5ݺ`T Ws9rWTTNXDD܍dl;v y"#tV|kE P'`_i-atqni2&:dܕ 8L]j'9`,7΃&F#vԪeb-,:R\`lSnxuQ,|ݞ*_LA.u%>荌+zU=ٹd㴙iWc!hNYSdHT_[Ir ewJʯ;0[#;!iPu:u3>Q7;<4qGI>媁n ART69wA$6$^5~zÇ kr5nUq0%wFEYcT[6څ?MdZEu ?!]sOQtfbe;'u|^ )>&f^*c[HkT;8~Ď5b2AQCHS)5DNC~ِߧңHu2!Nnj1i5;^YUv%yrM*z8TǑ+"gYfW/7|D֊L\[DV0F\pk<& Z} Cp=~+؉Z+yszaZ5 ?4c]_9Rz{هr^ d@uO!{Wnb£Nli A oʦ/]VPɍ6(Z%ҰE[mBѽ u><ڼE2o{hjYHQ JS.ҋOrr E.R2HG0 h8% lg?I|.V*ң'Γylv+vgQ9=A7+£7 =oy43(r4_\icKJo$.eNc=P0 ЪzCRG`1c:K8 zK6X 7TDT8CW>ֹa]UyLxwrȵ]ʣ]^xr56mse= +x:wn1$7_ JUt-6@;fu\., ]E!Ɔ/ĥaZ/z0Nະ̛VDNUn+rbtWTyxDò6Yp0|5?D:ϫTUnl{+&IݻXPvx|MUKrGdngD~,qF'2-cβReV.՝fzTYSʫV&XnAwua{^gNQFV9oFqGLy2gr[3huZ" 3NT߸.}wfo?7{wq%;Fnקǎ) 4_2cʼc)`I%b#INY${kn(h.#|;qif?H|쎚R*+ LSI.aVWK!`惁jq'o(WioKՂD6a12rXMӪgq] 39Շ (NcmU'>?8eҒn)0ڄ#fIb W]=bQxuXʲI#X4- ;cRGWwfyX.0TU^%͵B0wX+nr$Fa*#L))d I灼R}B92_V9jڿrn]@":"gE~y\̥ uspo ܖlWq^i@ cۍC$|+_'U7o `Gכ;}Z&*>d|渦5hTL>Z;?X;Շ!X]vK]f|Iޔp׳/L\X XZ^|CMN DjiJ$~ a |4N=UϿff͎E0Fd.cl%y/L,a!͎m8mբ( ѕD#NJπKt3?KRNvw3#&ANݝLi+ҦI6?/.GO_x;|}|?_:zލdfRS{,JG-6vggŋBA:[WeH^ ע&x-A-AYb v,HCjx7vxʖёfP207 SF̯Užq9V{2r/h1dI9ngo:WeFs%hde~ XlbY?; n4BYN"Vu^Hf{yԌƆ`(ZZQ4kR*a-ZA:LMޑNʽ۬I0O{`ki"T4*61׋ٚؔD+,/W #܎cT^^2)$_9/R2%]:ߵ tp$/:O+M|^> >VuQ+2jll=͇ڠ'v> 7݋B7X*o8!6fIn] b/Be-AfdJju>]&6Kkn+9J.} XwDtȷW-tKU_4ު ? ~өPe8 ڶӑ;R"D܋`dZ&mTu?BH)ק~.HnK_j)s;6V۲ez"+l q]:;;zP /ؗ"Rsو&UߗUTT}RB"QX!Z),5,Ϥ'_ۮY\ny75[헔l6&)0BO4=gdwaӊVؠ]#myN Z\ɿ?:$1`j{;Ǿ.tV['k,_'1AXȾZLfW\} ubgzCxDK U9f5 x]!}9*Tg;F0)G$<~!CQcTuߣnbȦJnk]:2w}ƳX8C]O혝,|C>,Z|镬C7oL!죥Ymσs~onE ~޵<4Mg;F@nekltxa(]e=}ԶNdFJB y[j1ڛpc[S'| dv19_]6s2y|P<ÔgUdQf#w$`g~ UA19.A7#` Ny,bt8XMΙo6R *V:N>m_ AD%]h?lHӆМ,Tr n/V&7~ԋh<//^ hرZ} PTc 8gCDJ(r%▓Aڸe‡Df j^woy"uy e[VtEU[,?d9CuwWmMKJ4ۃF]yNՓqrnɎ+/9>FHsIӺPٮ>u^7RS#19͖ZJpwHgE.ccLpt c^yKu7oM&q q$r›̊^qYn RqvlѢ#Hd$XnESɫ]>N2o{_8FG'>yƦ`b֑xc2]z1pg Z;6t({gr(څ+=M hT4úsUK\znx_C:~E(XbQ}uS%;۫3mH" *WX8/&3,W:C+O%_ۙMFkTZ+*+?fxr}7kӻZY90[cVZ|o>Q>PmzNx:CH7l<\ǐ䣬?>n[i>tX<ҏyQ&OxU6䚐+l44elLBń$b0Πi? Qg va ́BcYQL;9n2k1{iY\gK0t&e6V u:2Ϫ'SQZUy5LQ֊J,*3ƷӻY &V{#gkB0~*z]@6฻b4n۽|xW"ϊٴjCVsNԛ0`üǐj/|Cwfߟ~=8=U'L҆y:*nxl}a>ocw(FY:4yNY6Q~ Hhu*ؙBlqy<̀KI ubGw?Ĥ^L*q@L?y̓}h#=" z6"J <͗m݀{IR<txt8lEXVۍH>y@xÂl!VH>R$Cti13IoSF+צC7SVvw/IgP;!N7RNZv ) yy6Ԟģ&ThVFzPpQ XI oR&Y;?bBy|Md6~dp&̦rԭjwlZij9(N1Mᮆļ!bQCnbB; ѭiOf (ur%H'y okcM6a6격lL^mV4DN/D^Y PJP%a eUjɬ0wHЎB(Jxz $zf{)rer@Η5@af%iVdf=v.̩Kk-,+`on;<no*\({PidP{(qQ2h[?-Dygű9b0^UuV /<., i8> 8uCțE ݣ1.c???-P"eQ'RZC,[܃ q'Z:] fVç(}Lq1"\UrQ:ly"#T-RsRFԍ6sZ4}dЭ==iƹ {NIo٘HN`Ցt (pٌxr ǫd)1QpyJ|4D.4UP K: Mj$ZF@؇~ a-EUz5$2Eūs,yB颢xyqHa[9̎puy e`9]Kh)d"^yDQF2^E >L>kN&ؗo%cǝ2;ŕ|CBC6 Jpr&evv'+s(U6tq"i6SϐKurz;i8>oEwCNQ޾|mM)P|f+ܨmmEF$?AUE4)P~ޚH|=]wm{kw O+9 ]ZY(q| * 㰑D|I\j oFs|zLq_m Q>vTz(J_,MeQ1ԝo^m(0+ olN6TM(?[5Lt?9P/cs'җRj@tH9䷠ŽGM0q%|sZ#ꨝdS@;55n& !Epz]SWVngKI¤JQ1$*hI ҩ]k=Xy}]eŃ리J}B-&n!N3edt Ibېos4Z'en9;yI]HoSk|ڊ6"І3Z" g=di)#MMgؙ f7-7:1a+Y1^~rQtr/U{[*B+9Xtqp,0OpK10Hf~k`uV[A]lT"`ewT zA$wyf H!lBkWpD= qCWxDG$|GJH?:\_ ?TE-"Z vt4:|s:Sƍ-4ʩ" lZ:< ;ɩB֗VĪklϺ [K!f~WE%ybg@V% g|OYV/VגU;kFpe.+Y-NCͨA1“ ښlDk+vTnO%t,HzЁ٧sϠ2ڰmnfҽ_fb`!ur{vC\œ2 &L oVԱ_ꪳ*uH쥮qթrjz;pyz;oWxln> N, u8\%KK^5J,/r*] 0{NWjOfޝ-=Pr-Y Z!%z{9wh&莞&Oz8ݼgN[Lx;1wI:~b`]Ãz$x_R\X,'ȖZ)_g;{MKygI„@Rvz?ʺi/MWT7_58S.{escVYQU c!2+EI|12Q*m Kʌۨ52.񇅦%Ζ/ŧ lWA|I8Ǥ@͇f#&vMODS^D3e$ۯP6lY@F×،@i+.]Edd˫f9`B`q AaHF8g4|5LkIlJ͍ӎ6.6m)tm=4X6ִyN+1Q31fE~ZS1iA`D YhFf%f(B3=*p_9*Bx=W ٞ@gA L=8 '29qQ俙۬RzK+p-΀HMBU ˃ӞqHjr?v12IiQ^dazV%Җ{~P aSs'ݞ |VqqYmcNN?uԭW[lզi9wi8O x#|ע֣$1%5xrD6p>s}.q3,t12lq8i4ɫߛ.Kk%Uh;l^E8GX\FSVx#jVD]Ц)ݎ`}佀b1׌ۖtg)Zw:l^g?_U |4FЅPhN\HK(ij]>kaeIS|Y؈ٙ2j "WdU1?9G,3'{NJ-ǰMǤf+tgzOliaf;h?P t/G M>Ϋ(QY\0^ 2nwẅ́m4gԑ[{5Svd+fVr%МI{k6 ;×^7;߹o߹Sy:nLxs[']hGbcy|n 7׳7F VJ%fjVP{75|ѱ+м:T<<[襕~'Œ6UEzv =ljZ'v * yc\״tV=:6:KGŽ5ʌpC3{s;c 9P *#z_Ib}6q3<y9 +zO7V 3*XP&srX򬕇LLM :ow%vSwoËԊbO]!'J@+mdT:[ E6s 'd'6^a}疆:V[pWx1@XI6VJ)iˑ^ܷF"B)HWxi|/6Lo?A$&CO뵭ڀ~StR|lϨ˔|jJ6n!L[g;)ZLoo$Cs^9HXoQ(閮,{Ƨ̍a^aVj3US6Hybto.VBKNƫmmjFsl&rKﱶdX9 e31*5GEc@) uXOrGU) I,~;U'BsQ6!Ρ`Dtfږ<'lKsy?Izv SqNGL szG T}yJEzp8` yG}S9w=f1kfW6jJ69٨V …Q+ LR< UYt+[v%lk.lfK&g]H\6˵tk+(6+Ǝ/mY+E c`A/rnhR.\D]&}WxzM zF]UOLZP+9۳_pBeݗ>_VCJxRk@V,_ ) i=-.}BUg^f zC BHe(u\'Zz$?5ص喲*_sS떋^hԠSGYV?3 ,ɮ4/YwUb0wF"o[KVztPkڵOqTzN-!ғπY#:i0JϪ:CBng$:o.p-_ғc>?bv2̹Cӥvs@yg/{V%(/FPf-Kũe;/U͂r:D<$t=Ht;qXZ]qف~G>n[뢂~f:fb2gmB!}Q>͚/D{x|8L*)teߠxAWFLcyF0sŝʳqv]/rU/)2;!Y3dq߫& :p65GmMfPTQ/h#DaPhW|qNݳ1h3OBX%6)2A&7Ai^e#:XȦ @専l[9񞦈X[mu!ʺM޳ZXxi$\@lC;ijE$V~35pfoÄux*x)zh s; rkX}׌\$>93@f_~ $d7 NvWm3r<+>󧼘;$6Z#.Qyn#*A;: y$yPCi'AFkF˝X]xJCq[,'=<,p }ڧ?pGTTo]-J'kݡJ*L_8/^P:Vfßk#UY#.I[ 1Y{ /YC"9a19pkY՞z;}&hmO:}6RĥxZPt|Veܒ TX4*\w7ڒ(`G5j"^NʯG6y-E-VbuHQ Rij ;CQ&` 6'X~RH}Fi:z:Ud7xJYA 7Ӭ<S2, ,MKz} _}ՊM{ي;qq+ld) }2$ri>e7Ŵ6THzQԥuQ}[lڥIS*j|@g SD;Taܤ9ڿ,_V%F]6J}R)vT\;;2i2R}S4,К/-AF@H}@ 13ܣYF'<79q5JG.FmT7rFNQLLUz"f(ݩF\Ry-_JBa ?5X?)/cքgtgN/ p /*D~8P-ltZ,,k((.3 {NJ?–2"0B% yXƏI )~{~]&fӤ(Eq"p:(EacyeߤMBen AZ_y(@Soi5/䕅6 U4+dK󙷓ԴFnGbclrw7}ezkF+Bcg$阍:ccGO(|W%K {|l]lUmm~k(?Iy7{;goaAAylЈ?T?0N!~*\ap: /ȗVyT*NIpcz-ȁ5m+ظxIp-Y"F{CBD껢G3K4Cs0r.LHJ8;Qo-g,kXDщ-_m~ T- F8|?C2=Z.!G\8I^3wЬlCA|\!`6~F5dw(gOlkscdšա]w P6ˇ3mP|hĬ)O,O{"$@a|0fWa Hy߉f]9'#Vʈޘ%ۼ&k#hE=KkZR\ g7Z0H icrbkkII)QX%b|2=*diwt:: L+G# Ԝ[\>@h¿t GEfrh^{tv *a!(iu]UxZgyɮۖF*W73y;h7;7I'ȜL W *>sf L6ݪx-i㪳VON0edն㯄|QݩpkF:bsF@,FqH6,)kD ,A:mQo4/LM`o2wL˻D?̞9vPMA@Zҥ^)W8'gށ- 򞟟ؑfԹ!Set~נV(2|Uw1xJ)66~ ١Bjѭ'Ś]+Ubw꺟qX솓2 fU1J]թk~ƶ.ٵ?b~B`n -jL|ѱ%,u5|Ä6/%gWIAzmU''L-HX^7sSm<9գWqG*'S>W3:X9?+) z:ۗ/Z%3>Mo*GbCN&YZf܃9~1ZWSMBD_Noϙa{J]UÅ $X}MXrH~vHXdZHwExaݝ b-nrğ(2./a*[6L jo^Ks[P>}_'aA@OD&K(I69"ÝwA>.P'd6 ЛO߃Ҳl\8.>e *Hݽax|/ voV3Qpeb碸?>fӽ -wҺÛ=9t B:;߽ @+ tOhq )Q*윟trFBYY!PK !{ 4Tw7 zAZ-w/RQ:#;=.Nϝ3Ԕ4@lOrA5ճA]3XQ|I:_Í!?Y?3R}9« xFx96:JWZs] ypGG7iWǜEDBm=tq&cOԃ/WKΤce{F2;dtmWâ^[NZ(Ӟ\{8, fX0cF(Z(w+;5ӋN?dI-"9, )W|88(u kqO*(f8NASRF_Ĥ8P]6 Km=?䅉$/}i85Ė>CWx߮xʢ69jxj (Sn5Y9J0ϚEB8~@lmD3'¦4V(gxINŞSq$;Xi ͵/E]F[,/^8|Im]rFۃJ85;Bd:Ryxj6B`{dԄ7_ O9iFN$/s6E&ƨ#٣~j_KBζ+@׹h}2j )**Fp9#dU񨓵"2(ͰQ?#iՃ9)jT5bt~mŐ/FL {iW+1ufgz!wj\hhBy9.҄\]ȼ|FnK?411:հKC$XHUI^l.=\m[K,L3K|5$악sCM\ىϐ(mJk#v6fuh8vi>pf!A Y_O h֬PK6C7RQA9p0JtyX2 ]VZA u8Pprbn(1OLҭ'̓mΊ͈tϸa _y*uՠ,F}%ܪvZd<G'=?վl]_* r1ܙ{mN&N-w, ECȩoLq9qɳi}͓*7$Nѹ\y8;b !亡DlR `V'xLu8VAoW;4O rk;Sؓ3i/z#zu7\w_]ǀB}*W1&N&U|(>wE! 38R^s@0/‰a5pk 5,#˕?РY_3zqL ѷ:zkv߼^#r|-f?]:FGZeVxM†k "+đrmȳ` ߏP%۽z ,rC)n,[lA,Ƅr1sŠ^„TTM\yŨA 4G'\ C(2n/ ~M6GY u]}%kXYQTWƵ `PeA]02CRCkCFUxjW#L-9]BN)+J5/E_΋=/&Ow88>@Ov.(BR׍YZ%vP`Nysp /;8yC)&|J[gd>ch0vtp~~prܿ8< (  ?-rU\6om5[cq6(<>}or~ƩyeE>JA 7V4:)k} ?@ꇪ`ySS&XI*GEq5$[2]L2޷8@8إ+ hVZOn< SFhƜ8<丑ջ9c174w9;9Q u,& U!5w +?tHA2R:ti m_bpqv);\:)U苅NRGڦp?a~ݦm=I9WHe'~QB(BrzHxM{N;㢘$[=b=l7˜['&Ȫy1i YTPYdm=5$SM<^~lsEt:o `NˢGq, e:P =B2jIi~ts{Xf+↛41ε69z$ anYw /c5FCWa]@S;WEwu5cQ[>œ<'3jᦼߧנ]ecyRRP&_u`S晴l6ּy~'.OصI1[/(p1IM>rWjWj},9]pj*hINŏyRԞ3ԳM9z&Ħ9tyg}¬H:t1A*Wkhn(,X ?GjYuZ-Of ?APz#R3V3,,:޲;-ml,"M]J]Z.^5at UFhk%\ }fV~+4)aL!f\2NHr͈( aiPK?,} :a9W4vn,9伈5$MAhmYƒCǨ4arހF5a8>qe^ ԼAyıש@VJrKB\BmVn,*٪܉[ܬ,vn}= 2KjT֤-eCὉۼjD7U*F]ubz?U9~~ ~vʺ]Ϣs:{P wD C:-}G'<ʅUOx:S ({+ruΒoUڅ|H&ثL@EY]-Qeퟑ=ayB}Kz P7|Ŵ1 3Ԍc*YQj}}}!?7V S٥scܫ/(u0@[!Aax[lqؔ \ ֨l*`sŠ_\Cvغ%'_٬g#(2?j2-6Nd#ۓcwgWYrGqz8sTN0P* p03>k=4i_2Nʷ!Pb)eGɭJEɭRf:ŤT(2Ѱn1mcPk&]e8ؕUUKz6"ީgNOn EmMvc0)E)NZ> `(?`$ޚ|ǿTqh\Jܳ|D>jNl yfwy>|(q?M^cwA,4jhay<-zm*wHnfkGt*߂)WԴܾ]0+,bh(:= eW(v-*-S1ݴESC]FN\|2C3 6Pl 7t,bqDIVe8 Hi"AĀ*/s!qў[qup\yx`2Gb¿ݓn?-vSk༆S»LRVtk"T"| ftUj)sDNp(xF^1F:U/:.aهZRL,=<̀~ϧlzsMb FxzIʪ&r@bYչ"G5O *xCx)~_[[[wvί QSLubd? pՁE[k]v]dIڐ?f'Fd4Z鹵x-z-wFhRVEJڕ0+'JS* zd|56R ̪iyob2&uKS*z-)VZRQ~jt$f'qC#= 9R&qփt1ml]2ٌ"|i>نEA6-Mkfl< m*Ĉa[HzPbSoj|VA0.AX R{1b[Jns{DTpҴ .٪^6}nZGisn} 8>N(uj9SQkyh -PvO `}]S"ǫn%&[ >[tjJTWhbH3-IhG %"Z cTpvi8. 'Ԏ-- E*;1XE+q+2W, %;+^~XMɃsbMzQ`ۿ(.F@ ^?" >y=:Fvw|&"v’6}[ -aQ2 ~{̼[ǓƼVHSA}%+/,y@Z3;mIIfMϕ$ћ0 *PCW6Ѯ1ɉ90hA+@,g,Eh/dʛfY\]Gm;,'C2)5x{gǢ#k.} SW}p Ws:]odZ=O||yԖXythI!4I6&Dl^PZ.k_]E$ƃ=eAĆHxtM.b+۰]( <ُ^Unzd50hhK \N[.$&Q%4}&&0usE:)x>NM(tiuwQ0): 7։]lUkap(PoFav<> Y`b&K&,-y*Y k: Q71?B$:΍L0CBMK,#y|A>kBGՕgӴ[/V-Zfy^.S詙YYSp ud9SY6vbeJ<5O1'csNu>a抟j(M)-aEr}/.VҖYMӑ>V+w[|PXX6E-Ph*SYSiIC۩ 7\A;8K#9G k;x\J/3]pQϚ (98aE򷴕ژv'3T@ӛ%2":jrӯ z."܊^ZȠ $a[c͙rHwz<5ٙ^Nq>n\O}ȫL lUS3 ZhwSعU.BE!WEz-|QާPp, kp_%F#PYtU^]ea={>g*򖶹A2q Ad]n5r͔b\#_z&Õo?3yZqKV[n &zXkx]*Q1_b#'+kY, lMAƑ6;9-8<7ĉěMNhWC1>;g+8>!xaۚHOUPŬu%o6ĪY&)lf4L6ƺN 4t 9J Քgj--(rl4#ef+;+\,̳Bm )~99!4&v>y/)kC` X1ğ"ۧx~TgG-ӫ`_a4C>⋪M=?k0'hF~N˜;oG1T݌b(,#n^ Â`uQYSnt‚9*ǿp![pNkzm4w8'MQĶ~1-k `J_;[E۾ə%<.o{ [2șJLH-!PZ8{"KiX Il@JhLΣ5Je3P8Cn*ț>w6{m{Ukt%5 jIDQCq PD,!lVg ;HUȗY]|a$nm)b:1H-͢`_IZx='lJ $"}wQUPJJD'sÚ]ĩc=Dnʾ1g;'W܊w9*" s`ɨm( o+Yq+x f:Yj24TWi0dѯd,̛q6wriQO[wrε!@S?s~i-O_8!?m[ M-Ky/bW]S0`#p[Y Z- N3/Əi@9If>M(& >bPylѱ?TPA=QsIO"5~~iMn;%`m˙& }{VvlW:%]~F=A'AG=WܫF[EbZ+VͭAy]E%C_rWpO3#[73 6uxIvE%8?齃ݓOF^RM|yWI6d)uW=3x-Ш8ċtSI49ȁTEW-{muk?W|20郀3 1H`:$ |!Uy+A;:/fUi)״C,oQ<%[ޏg@d u Ջ>5'R~z'z_Yvh4&|w餢\`?٨ڏףt!](t\]{ F>P975I9H_Zf2=߳aCv!|]ṚC;> 1zǝ]>b[cײraEI0+Biklr|k,k)8\W&pZ4[,BJ%b(e鮟bf769AI>~b{xkeqQSsz3M{J{}_%!E_K +gѐ7yK:0>sC7t;sx>XC>͒4FoyH ` g퐇kmƥR@xM{GqVwц}xXMwqi+ig7EC7\I`)qq·ڸs;>ͧ3RHVC*ŷ?g;~q1m຃eoQ|"cV8#e s5ޚ]mC(x'lw ŴHN !OYULjno8Uw Pk9cWf{47E5bmѐG<HTSuaVKǞ h; d75GwaPWll@Ӈ`0-zي}(yV# pnuNkNE_xn4Bf$-wv ֞نu6Wc0KQQfH@>]6u٬ŽʟQVLWA &Bt 'AIn2ŝ\MF %:GKm!`:W:ӧÿߟ\IFClAtC2&. Zv/gN+m^N;M߮ճgG'@ױնBV2fBOޓB d] ~rQ쑴kQfVfB+5n_EfɊ`6Em?M]k܍r'CSa}5lnlku`Rfѕixqt8ɵ˨-Őp6cUcPVkCJu*0R%~-a:6FP̎C@૆X쩴ue0jA[gXfg4(_mu=H(0Yvj+ /,Ln49y9ń%PKW]U/{4}G =lo;ۦ~kZ%BJp6EP&:AJ|1_r8* z\F`n=֖t_gol~BEq t!^|yV,L0N;.(r6J2>CX˥ڰxw~IGNx?YWz4+Bt_g%w1 tLcI\sPn2Cڧ@!(' qZɇIo<56RƷLbOX@]ۙL@F`=`YhhFw}-B2QW 8S*n&i>xġ/шΪL0 B.x7+G3_?}l-\7YHwNK „1&6<*N-xUwr eCFKM\ *Hщ9b̎fZ#^E`9z+g7 SPzP^?B\2zJD\EDOt֍`=W|zvps*9&NJP>0M(Y,b,#Z'`_(Ө D3]Te ݈֙ ݲejfm2DdPRюD;T)|'(^I)! ΰt $6J :\mZ-daE0k){.,(Yӳ9,;5/⑀={⧭Khd hj?s C>KKe6Xڎb)/ogeU8Y|f-t?^~si8ˏH5d Y\2+SMQ|WTf:p*izMR,~SjDC>KGG葹=Uٔ7TYzH!WP&^*ӻj nM4e1T[[V1qAm>^{<&4= 9:p+{3Bמ1 FAx +_2NV1>@@&pV@Qx[ʄt诇Ӝ hC \७dkF'd׊~4\̢VsU4sH8M q-Rub[||/xD1;އ@'DOeT~xbk |uƻm:||!*ߎpr'Y@ HDQ$l!Xe8Kzyw+!mE{FˤvMK҇u]Q?yhNO-*T @Md2?Q3[U炇 `j꠿MC/cRSzZLYgo M)4lujn#Lhn JYۨzi 7捫B6VhvSC|eMiTW4i{|aa;9 #"[/'#$Jg'e.Vlݶ^;8b7d[[3w%;e75꽒lBpN@UPb֩GV9qt_̪)#\q"m|(MW$ěh\_UMӉ.6E)w"bKWiF7GnPk[x 9khcAZ5?rxDT9|p^C:W`Ɇ!\:Ė PΚn s~2x %v<=#jJHP@ nNfS8g{WdW+ߛ5]+Aۢ<|-:}QR6-sW)=_u['w429y;}oi**&Bެ6J.ZD~Pe),|} \p=rj]O)-#[K=S IX!h[hDXkMg*v\fwئȇ=4aq xOE[/E!ɲT4 OQ8`EEHGA ޖ9G‡0huzsrr 夈DG@ȅ(ml9f CnbK?U^tҍ1%^9S|uJITf<& R嵓aUFZT& 577N%>kzm_׾ڲqDe3*e|)HdQǣ#y)oֶɜ0k 42#9H=o0Cme1qaNʄ.Py4Η7@oZ7u}E'#.V:uEDPϗ1,xRөI&91 HeE5|1.4Mjj&dZsC!d#Z٫3m"Ͽ=sx,jQ6 ^8&Pb4l\|ȕ@鰥{g'{'?O.oO.":$<N~tx&-wOGcӄ9t5<MT"m8Qv^?S`㺮j` VVdź#0J^ZZ9b4wcyOjpŜc4C%"ȼm3BÔHRcMXp(3@{"+ HX6%I)|abk* bAxdzC6G̙G-%Pq{,k*iEps!b$r*θr RC[rQSlcS+W@36,fl rJN_~gzoSuS\T9KGXHۢTD16h9#npvNirէC a'֠gcN\4t/I.@暼 tjs ZL'e5:ũm 7CdXmsEAitgHҡb?q~:Kx(TT]УhqRF=RVUZ48{oh˲/(5[m !˕US`z ,y"/2䵘]yYR;O Ox9"f}5P=auiP/񽤔U4eṭ!,Erc6, U*TpA^TuA(\էP{x^uӞ6mA "m"^!̾]ƻ1߾!Vqei\~sZםPn0sl(Uԗoހc&/H[ B? VQ/YvYK4D\83 5hxb_{Vo{.;[zi ٜGm> k&$@[.NwaJ#JFl Vӄe]7y>$s@#ZUTv?ǮϘU['D"A֦߿ SF;n!_- Q,a̕4`,|Рʱvu=UD]umiհSrd =Q2ln8j벴 QR.hV{w'] 1  뼱B;F 4r2eAABoqMtg?Y# rͻ杄pUbٺKRӦLJ%PrJdMg[qKk1P ǝ!RTMLj;@6 ȍBD^E~ تy3B.rO9}mJLK M +D^&{=q.6:&IQߋ$XvK`DKs(K`|W3 Xjyͫ)yjn&oӽ4pyty'cRt /*em=0jS6Vne`Qf-.bӫr@u3KTy/@8c؁Yx*N̤Ư[|?-~ύa Iu/iՖBoنp<8jloM?h(F ^&Rf&%6M)wP{,+Gj]9bUf"@XPPO`)JlFɐ*7ۈ@ ]]k6l*tb#Rhog:/3(#xLll'=/Z9~5h-g~D&ۙ]^:6Mjj؎\k |@$b Y"|M/3@mئc_N6\)Z[e] :TJl̊"8ZU10<ˤ1h\o\2' F\ ]6QJJa2DO_/X`,Skn~&G&" l&)$~_7-;'`v5Lμ7S3 ) "}`x2~m2j^5}}~엓_^+` ʹ9w7()#I}#ue袹fw_sݢ3t=kj*6@0[(!n>BT8!:)D@dtwf6Y7Ã|S+Jh hxM~+޻K|:nFl zwv f64vQX_yC6\Uy;Φ=Vg)GD4J[BJ٦qT%$na YI~nOGr6*U]7jF5oyk|:Gmwܿ3ϕw旅M  "NcGY2ƴ*e,":~ftZ:ϔx6~%X+H4+ DoZN[R ּ@n/Th^ܫj=t^ .;G# 'bMTL3w~]/," ;qB*` Xy&5E. N,>6bT K^GMܲjC='Jx\x8Sљ0nF!t8n*WS-KkROR[^9тn`x!\0?Ot`t ] 77TDm|ŽlC5* ^*m꧔2Ѐ/FScD1_~Y;$`Whn^I\3@I +jXl`4DAW;SЖ"@,Xkb7LF$(X^j}0ncCIӻ+۩aWN|SS'Bx 2JS6,ZDYaui*x27ZjVȗ C&T><th-f鍛_*PнLg}'HPNe#YTrVuUtQ|}V46Q4S(6&6v^ O>D }N;19FeWHƔ?SUigj]~$Pa1ר8E^ܻYNm=6n= W'm޴efkŸ m3,\ّJ՛%u-Rge ًNI~iM(7׼|thc9shnRN5a=9l'{M%L ,NL-UZ`ߌ\afmEA=&l'I͒ZM5 8ܜ+&RgkZ[CbF%E؟ulDFP{.dld,à e N~z$cS؉~UTg?leB[O> S_.ف:ȍ7+> u庂ͯO qsS4A|6[ C|Udn$Y2[P5\] VQ$VKϭ9kIKCm%5v/Va[nh!VSXMKFV..W`:!|aB}xU&WWty%uE,oVanbLh,^ fk:EtRĿt4-UqpMM@V3v#N`ZDJ@S>#%3^>N-U_^SO(OQT'xz=)XH)|}if`\W( zDw(gp$!MTMFyv3 㸜'x=بxL1߫?\bgE7aP%Ts]h8(c2Cl&ReNfdE>L_LI rVųWP T:ٶ?G naW#Z# j"φ? Ik&X 4FUmze*uw;A5~̱;ϑ8֘ 1%o]!M_btӋJD+ C@m "GOQ%8[Ƅ'=RaUJ[5^l3Ԕ;YQ f:oEd:vhYN탙I<׉c԰(QlX*D$. or:iMΊ[U RPfe j41KgI ^k$-~JIOby[{_L):u;(Ƹ[lF|<pVOV0<OPaq|&h뛞Uױ=N; YTNh8=ைqOoR;|d7jEXeვ_-ryn70? 0l7i4,۬[Jp1DVy])\šZt*JPۊa!ݕRnoU1H%Podji:vE; mz TpEkYW0b8m G,הT|P[pU_uv)]UB۟1>;^Ґ5u+ ҕ*[J qAMm,%]0ɆwEH,D53F}J՛N=N3gZ߹-H_qQNA\8f\gs:{h6G֝p _lUnJ!k(BD\ndh^ꐽcDqKp"^RX;9E'Xv"řoܔ=?:2UV9m3z1|'.LpC+;'8iжL^n5K6 DO1,'tV7C7kЪ4Zġ~Ț9 tZrpiGs(@UХm><b\{7AWYC]d~%eb`dۥSK:=`,rsshvD` Ɯ++>6ٱ{AYi{!)+8ҍg1dƞ\o˻;צd-o+ 0𣾛O)2B᝹#0jG׮,gi5TMf<a80d!ZCA6$w .sMH0&od~.‰JHa>P8}sApiS(jwRH_|+v7#ہ Ϋ7=bPsLIejxyqѶRlgNYv(+znwl9q 7ݳN'xW&~Vc>vUDfh,~9'%GgӃ?9y5Lҭ֏O֟ӭO7ӧOӧ?OJ%~3i??l?ǟҟ~H7aC?NE/N`*+plŨҥ߽9О1*\*݈U0} Ma>˾&sgҏ?:h2BX6pEB1,HB;p2bHbʟ_$ۻۗS^'t Y4P;Q`|Jr! 'du]uL:Sp Pånj W^i(tXU[G~ p~LKQҨfᚱT:z#ʐE1P>,;FછP\XT2Q/l9 ڐmlW&l[-t:r4Րp9U/adJv~aYȂpOJH ޝD]dj.Z!YUTfdCvQO% 2蛱WX&ݚU(JsWdV(Xj.ܑ^L?fyEc@2cX\; E.JUhW\G1),+uG+Ls. WԂiʒ|Zv;v&l[Oﲵo:W>I6PDh0qGҤDE~YCZhIݹT4`v!ٸ-un!R`t6qi2U|z35Esxy'^6%̛6Cl, z?9'۽)For\u+*G>wI߭Hh&n6j4y zjjMޖo}x<}|r&o~Eߓo)h?pZ[|R&omxXeDp _)(1E9RGq: $2ZrlP&xǟY9Ņԟs>߼օIvtjeLT!аg17';VY$C,Pl%B =@{続n4[haaPŸe*eAyc't4vgz . B +Q2P1F+sI*P2_4h+<׎J/N3@EkMl3GML-ǡiNUFZ.>+?{[(Z6;zu.l+pӓ3U̩.8J=c`{{'>1'BG_jDzS@Xmmg<d:R$BL( ^Ӡ(9“Հ؈Jgj%P'eMMti~-y*Z=aOnePu.vȬ/Z?G)<~c%{+q1T觹{6DÃ2~ʧ.} sq\*>ϋڿ˛AXcǘB7;}<ZfiG-#H:V1E(փbgJz]بt?SoJ Ai߳k$)s fL:E yx?<|oGyi}0k;k߁1¢$wnּ&G`Å턞UΣ?TpNGYC3x7M͍ ~l5=ʕ@ 3P˔ӌAQ:̃H EWFhgZb?"%Hvd$;(r­|[eQj :bj~I%gU, MSpכi`q97dNjP-3!Ť9Tix}*jg!^R,@bd*e %Vue6N{IӦ>I|nbb|W*+(l^+F޳f{,Y+7T߱$:S{n=^mmgo}x۽̰m4IsGwt)Sddk5wf2CdFUwz#uTŰ%M s6=5.,@2;E}I+ S~_OUfV|w76doj_׮ Ql{rNVpΛۻ}ϰ_h\n1AP[j32sOD8UzhA~wS0"R]X$xVvڠGLHy)։= u"tn>EьRl >ȸ !R~C3LIm;*GAJikfgA])`q10}d"_(eBZ)[wϸi6g^lXyq0̩WSMUfN_e}ϡEcm-(T*_BCj awΧFјwAu$,-~a}WϐM+\^WٞyFSJhʜu*-#7jT$Gc"&bZeJcZ;[pkOe g&fQ# UGl X[-66rOG%?q}󖔢xB`C)M屗/*ƒ 1v2&ia#)NMhXsV4T=4k +hSNmШ0V \w7)8[[":IX+ (4YϢs kCL lɌኵVIpe4Gg>y5v 7q0ihIF_ތ{|ǴD,rBԆ;q|d+1,b_#%#z ɑa 'L|s. ^tU SFĪ%7ݸ}6s(|q+beUn˻@Ǒg2ȮjVXZ^mzWF^"G$8Ej]tr-#n8Ǣ.a{tfIEv B07l_ПߖEֶC;?ThΒj\Gle $nB4\(SLH[I5uO<-Xpd cC~AMEre([t-p9 U3!\k!~KZ6+r' ջ%sung)x]FB>$8"]:Gjp_e*yZwtptdkn1⸱ݽ~5v4׎No&?(@7OwO^:89Si_˝7ǿ`wUY=fr{6_vwKi~?pv^lDRn緎< r|,Cab~~TfPFMdB>T8p)}+lUzV|OLu_ۛ5[TYfk]ʗ~< ܔ%a/#%,/5Z<ǝMHtQ7ΏTWGCō}ЙhdڨK1%s7`u &)z7)=1i7$|5ώ:U0Mԩ&փJ4fL2$:~"a(f;}:E6 )^NfyD9_cCg w w~/bpD:|v.-4cM>ڙЏTH聿`59bZn LCakPx`X , n09YKjǟ' Wxwf v±-ծ-DqHcGlGq5O~EHo]o;O.oyCO@px|bkD?`@dS +ok 5nt cQUmʬJ:E1ۃɔirnpG_Tc.:p X`!!YC6E͊?4/ y"No&S6 3]M꘥٢y+>6{۫rw*|``!Q=W#;}WєOtZO\,iWm/֋Y(e6BY#l3r!|UOnņf*{>I o[#*7>첁+Nfa C,DD%k] X!<L1^X6nP /b[t5!w~=P ')"EF}|4JQ~$k+a@E0QL N Г1Q12 C+OpU"jBIp̚rw\( D=ge0҆*1$G[vyAH އeWP@{ćp*NgY|g6TۙR-Dky2V1?\ǹn@+6nFP+á5'$ e:ܮpAnۓEu 0(\0wϸ S2Ν *vOyz;ZON5VKrPDtWMLզ #4Iɘ;$Kp 7,n/O'\1!Yp?+v2J0h"7OJ̐A(`9-hD@w'Sոmuؖtz>~2%M_ޮzE< }ꍣgYl'U@t·hr e% J{}݅XcRT4ڴVV(O>Z@Iʅ0Xib0?n>!򹰩[԰SϾ=pL]v݉nGSNZ]\K~J-bxg 68u'4܎%6gUN]#7 ae E9kP-WqIz9-J qhcЫ0}l{&n #|}繰ؖf*:1a[0 aQ ʈr~SW#e e5ˇX-9kTU{^6^FCNκTY%D޽"w{mӗ](ho|Ϝ]N> &sȋ &Lfk-c녊4kH_]h酔ڒ kn_]5Wd\N@J=Ap8W=k5ݚb0 E.ԍ))PhPwǧ؊Y[ v0ig n`AJ:W4aƜ07i!ǫ[R ưuMno!w ?<_4I`8g+ l(5T ǰ0MT\b)hn,o5%b󨚵ް=)`[-Ʋ_Ub`︮JiH I48Ɔ?LvJw# }zC>s8~08-oT9uzQum.˻laa e{ 8n,,h~ɰ.,xWI,DcڹИ ut M*' K7ޢGKL1UBA^RPt)wlQC7ᜭFtއdb*.L)R.>YKoИjj;^qvKhv>8{ ''$R>#DNV3 xr+cj2\"' iK$|);FZLqFqv^]Do]B!d4yu$ql^J;H3JEfv4{ޜ4׫ZH# JȸtI-oVޢ,Q,%d}3'M7@sZƸ۱ͳy GhtSG3/7&5- Onhg*\E1SP>*W-firEzIN'"6 idxb}"}L?%g#XJ$ Lw65汧|p}K#J\M~SjD(*kp3-.zŬn5;ilER߆)l9lK4(#9@*8w]>j,ʧ}ZګRjJKt#aq#^966XgNfcDĨG5)㞂66T`WmUz\W+ho~%2|cΩt6nqBK3®GXhi3lD'IAI?a4fπSdR`70<:Ř: V% 2&lSCڊ zEhkY}I<mW}/?+6uv$NA/P[4PhD c U>0lKu]ϲa{}ܺ3y3Fp֢ReUZ땃W$}k:s~5tIf/xO]vOFHE5K{x~*9"ǓbD`hё[̒X[X|[ .U\RŁ7~AaZX !n-+-|Y"$N.Wli^AjK޶GGY9~AVaQKHQ'F!XM{(/juRϧyKǧ'ڝ!2(оc{k0mkxLqi2LDŽ7+CTx?_O 0/Ô3~Iu (L:"Cc U,4p솜r@aB뢮 DoSW zT/G:Bm1sˣjmg32O`=3ut¶a‹iǹƆ/K3lrH*Kk0:Z 2Ma>Q7#<ܺ7_x;(QddW‘T  2Doq]fD*V)su뮓p8SSjÍ_";`O c=,=4[u, 1)R۱j?W R]Βy蟡mfR/!ފ;DZmDV3 4X8@H,1g Fp,YJβs*^Ymkmrms c9aZgbٹ" QXncbfY&kj.7Q>B 2+Ur\{LZ%dbډ2FYj_hRXՕ"Ύ@*ދkW%83 >8\u[!Iԗ>T|Nl {ø  U@j^,3oUX us9^v6*ԁ UidIU+2dv{0C^5d>%^A_yԣwpg( C5<"uKlp=;+@m*iP@Z YBa.5 :x76s IY"E}tDIk%FE&}E[ y/$$OMDL8oy<Ÿ=')"I wSv0.A5qm|bѺf6*:>ߏ& weK& iEwQ~LW^eQ>NG~_z)@׹ AR5 +squ%ib"]ߊ1lR-MN@nqlkyUԫ[LΜ]څn9YmSE ,Y^\&/]$ ?q@"c Jk^\Gg|&^g,wbژ yx}8e3Β&(zv#k ۊ~-:W[(Se*A-1J΅)ҮfbD q_muZP:z2 l0j FwdJp}{q.g;$6opaxNtԡ7͞5FoN춰]W(Z]u.H_Hi/o/Ng;'gtJ8L^w wrtw,*pz5H9Hl U0Uݼ{Enb Z6{b~y*{St)`ڶirڋV3&jϺz< F-!5PĺH.L޲ üW7T{֩̌H0Ұ|E3bbf" [_[^N.3m+ Kz$7:STiS矵P?{(WTj $*c>B\SRg O'.QX°Z "ggژ {ps7f>W@Eʿ㵸H4AĮM@ DD,ً.z#K^~y9I;PJ\,<ʒE2 AuuJ&ozfmou٭*w۞ͣ0 ?MnuAku#:Ɯm6F=A'8ъVKZqAb DƠɻțNF5ua3HUx!,;m(NHQݡq5$i葥̲#r[86x͞U]f l:jNJv娼pe H{x$CmKjv϶X 돑[^yЛu e@UN_"54cj ؎ JAtV%q8f9'A ժA-:@"vu ~i7S??A,]i b\NYWPc?=_6""%Rd1ݣ~ĴD jN ЮC$MnAIk hu[lv VtޝG +3C%P0O\/ t&f(ꗙ;qqJ^սf(D;Uv[Nv64%GY<1|Լ';PG WTX\ZƷ\>yYv62³ YJh{E[ګd~)._y' `&E,=$tq̀vF3s K[k"[f[Z/ӡ" #f"1 fB讈Ch }G._?1hӳ|*#&?ᤆICEs Ξ Fmp2?-0z! P۞M>I؂Exﱏ&,!jIrb9鱘Ҹĵ`l\< ‚/ڸ6;DbTὋ/=.h"8rHRKGǟк&woELTBLBfu@Mm 9|ˊwr@Sk%ֽQYϰX*l"<񹑓)v,j֜~ob FKJV3b y0z Jz2, 5rgmP&C-x7L7IN4Υ:av{66}zPSur&bEF-FK B??;rCA"~-*.yS#}X]m^Bϛ8hgˠ1lDҕ,,Qx\޲ i퀱 ~?є! ~nI%5⾶QBo|e \DkOL!:=Đ%)WϺ_=Ţ^-(NlPG=JΌO]\9~"hjH71V`|#YƘv}]1G2w-BH6jn˥I Q2;5NnۗV4EkITC@rgV ] PTש(#CLc>/@-4`u]82ɷecpV2@I!B^UDZrmÂЍltʍw6 t~5n]WUT͞l;8#Ӛ7YF@7-,p{  fճ9L|-J`Q4<[rxpx'([~` \#"%ǗwB[([쁔3umXkd^҅413Zk ?$ sdK>.jx~*M<-@GhJ!]x6ݧZXĂDMX[ 5LU#!'fΧÙR uI$1x9י =PJ`݋XbBzqgv.bm;dx{⼐nyB. ]dFuB3:2a+vj;nȀZ\dXIӼ|hG; )T4ZUm4*8UU7o\2ݐL n| TFH;uʼ,v; hҤ-gl}[dwkƓxɹV|*% r .Խp@#J@~~w+=:? t݃u0\}YU۹.8\&l*^tɠ#d k7 R6\;ٳ&+.!԰覬=2.mlUE,yG# $'ZڪmQE;+i0=)e]ā{GMJg02QQ AT'oR}ԃĮtV9!j1-tMZ 2[w>+E#>DL˸ ص].2TD5^Դo˳!KV\T Y8Aj|A"9I@lڰƹMmSFz U웚j*>뫟T-z(LtKz6Y1hbV7ԉzdq~<2oxܒ {9(Hk[>OVSR ~BD}RQ,KwI$]HQR % `ULc1і3m~$6Y{Dꗨ- aq0<8XK,,#ŒzhUVB=9'Ћ]^ugjqv@`YZ#2?fF_~FF2%L_b Uтzs{ M4-5i"tY" j P!*rqٞΈg߶~], g]p8tpTawD8LE8#t=,MneFPwsiq_eDI;a"dwBhoa?nJȕJXCq^QB?N4,'AF*ěySO acM+H}Ȁ'u6|n)JC](!sq£~Iɤk-IBYz"ur`.?JuБi$6~hm4tǃ)TRsmӨ0fM29 cۤFqWRywe[OO͖[բ[AsUpyaG9 0fq2<1-^Zщ%O%f5sR!v4 ,ĵdKe N6׫}[)clmBi0.i2s~ؿ|P(}Uf^4O?/IsyS5?<ꃮa7Q &&Λ_vw;A>XӃޜw@+Gw󝱺z ʛOv@@&w`>MRe?9==dT5JԱ<ӛXݣ×'?|0!sчM-;?=>8O@S1^h{Y7dqXk}7=efTULLusE3:q8#׸p7p~u ,Ǜnd- rC^k{L$N;d>YwVlVmz#ޥy]Qd6T{FP5vX1\:n?O&E.ylS9N;U#|ttǀVoJ:^ /9r77/3T"IO/|5rrFpAX]Vn0]/kPi @DaPU-7pٙAEU6^~U?}=v.\XӡQ>cʇ4/K?ڃ ^gY448FsY 0;@ z6Y]Ƚt@G=3a Z_赣(V|vdGzD}0xA*CwtS Q[[Kn䋢Ӏ.ao .3<v;qb ʵS4'>3U9ws>sPzjWZM>a~x0Hdk`.%"Yw()6U9{f ơ 9'Ǣ,7c}qn&_ӆv!~ uyPȴIm*#;QӀz$$ +YTnE3Hb(a!wpK:ϡ `!Cnm)LU TdbS xsvvt~`;-Ĥ5Kn{fv ]vz A5Zh*&4SSs0?wn~piT"Yqvd/1\9eWa^ZUJAKs+jne-,`eW E_ı~Wͥeƕ\Oٷg]!w2[\&^3DCYJt1Wu*dqTzU6_ jjII* AIUcu|1~Ք$q[^['//=V(I8l`ln 5ͭ%m8mRKY E84՗%)8r@kqC!ZgW@{66b^*Ư'3_L/y_Uy_[pu$,~F]xdr鵱<)_JZ :X͸I=_烩]^Z(P!D<{[c&uY(${MWGMՃ_&ŸQO~>?=T(`@~rlOν(~Ϲ>.Lͬ;Fe~}Y΢eD蕭נxDW밃m$=tѬe^PWύA"xrZ [\?Q{Zb;JP]ɯM 2&/5qh!q=KT&AZ%-ӈfz xG̯nH݁VNFضV CXTmO]-|3h؀YUa :8=Ci%(Xf,U; ,H[(c*!] >Lf ]KaF[)b>]5zGӒ3/wۇR Q}J20ΆѶ5˨2;dKS7~l]RHa$gj?,B-_ Y7$mBptɓ^i#N'pQ {a'fl"ׅX2˻`U΄ SYBdJ>k ֱ/}SQ"+ҾC׃Gq=r=3E*C9 <&ϧx/@D8_S%>+%I~&'߇\;X#\T;[> . 7|Q{ߎBoJӄRLF3E)ahMN!cn$EKtc7Hݷ+ CCIfCIx d9KX'ͺMWuTRKm}r[TJR]9Mj`tP#61v.ofS&E9!YSA.0jK ,D;T&c2c'WZ=` Ap:j+sz->betQwWu4:X$l;g_/nbL*?VzJMX-~UNicښ Ɣ;_W/rpL)%m2Q*O~q6zC?sv[\RÌ=РܪwaKU@ ;Rfr?iKpMOAN~K\Wk߯_%H~5yt՝卾)nO;f7{ګGv3Ҩ~px:|u`Q󠚿.*+PU+詔G:+*{^%VG8L 0;CŀTqld:A*-@b+wcnq2~y S'θyvPy6#-G5`StY"Is  0-Z!odOm3JPi{BdR)e#dvk y:9}uXoC:bkU 7zGM%.>(o3],gevPn$27׃!FCY:pYy} q'ҠG$k nz!.?L)zXn  nB@cktRt~\g憽y^Eܡ>wm4F(?B",'7*-6UCh ?нSjtaގ'EAP&F-̒w"kj{^4?Rcmtk奢 EW7fbhYSu\џ@j],0zt2BJU!.#o1Xo#!;Aݵ{@5. c5!Q" zׯmOZ\D-eM`=RpwtV^ ߝ+^qr@XN%R~h u3v[0A-4A".W!I]=Z?FO?OA5Z}2 y搎/ek v&ND^E 9 _= ^uP}a5n& 04Jđ;v;vG3jSK+ B]iq%CQQȤ!|b찾7K%6*FO]QD[DsnbB9jG9, ᣏ]zDW*wto菙 pM5ۨ CQn'ฺSuro7}1ك.z.X"!;qG?V~ުA].83/Pb0<}В ':ҝ2Bt,Uzt÷tKٰ e6n_xIGih*D:vqfܢFFl3VP?==RQ:($kabf-q=Rx6 p ZAiANڭqPO4 e-ef"c? c\Tу\GbzVj "wVK͹|oC̽ #[MEj&-Q (\?!GdQS/uISY5a0f+ @GE1&zuɈdMWѨo3$J>ߊqЄހ߸iyQ}Ervϟ}ᨕ{cu޴lUTk@Xoy̕n#ퟋ4m_OD26plJkn i1rVGYM7>y3;V|;.j .Q'fY5TU^]t`]#jD~%>Hk1F2$Yѹ4:܄TnVB_ݪ"YROa;ʼnwT`efanp(o*ER(:dt.(r#B$`^]1n rz9u 1Q:փk78tb՘ؐe89ϟL VIEy@<zGg+DQ"ּ :^ W8Wa݁21L&uȁ^Gpm[VW _x>ZR t9N@-)8|ZD|xЪguL#hwWCW:2S{JQՏ$gߗR=qbL\d@Sd`桂Â՗C7 `.Eh, ,Gq~؝ sOrciw`w?c܊+y%1=knnaE%V-ZJZڭ؋#W:jJqݥxxV|=G_RvR79}B>ѱ/Ld޻iu=6jt{Rx컾`R&zSgI&TB̰ZZ "TN$6sSA=R9$0n32+8\ "]H%U;PYՕiFYBri˘mA45WRQ v]IގJnF*(p17u7&6y{l9)K<FlM{Z}ֽ7ߒp*|䂁%Pt* 1Jԍj,^Z*{Tsr&N1VF̉z&ѷu %a8T-] f!.Ń`Udw |[h!oBK6[ u\?-v5طʛ;R'\|z{g*{vbÁcFVM#tT`{u9؈6q6EG۠(sMj]])v^èXtm)f_41NPڥnMZsMIbӢ+n5-3I(D jjg FxrA?ѹqRfV Jw6s{BA^-t]8-"񮆱w}#ZYԠgqڋx+e>6ciFjcY .Hq9~=yBeg_G:f]9l 0UntN6/X#S0 +>1jĩ`1'zvAdjr zլPPFhLlJ*8o5G`byqE1ʕG]kvhe krLF5-%kOH2eJxS)m9uajv^j)vi^3?^_nسU=b3+YşaAl۾}vGϜ<65A >_$-|~dK_ T'.k?unLn?'}6.݌&uQz_pW-PbX *欑 r⒀jچk 7 t؀t ¯C rdL!? lgWQ_nnz,!<6΁bF}>S94 i7 gs. )ݑҩsVI0PtW>%&m{B Zhޢ4kׅ@;1Du`gfW? SBlF"&e$n`Gku$8~Lu!/?18,hk㪨O抧XJz$8tnn gY#^bCOx ïSWf 9s"d**j*luXq&f2C}ıQZQ^$a A4AE܁C~}7E[l=D\N?!d難Q@pǭ*b4;Fj=^ŌB+^yԱ? ;K,ٿ:ʊ.E+L!{ԙ j}H w!*ґơYT3zS"bDIdN,Y"a@:@{=iBs`ڝ5rvb8_BYK1f.)a1q|^fQ]vĽT]S2vf=c,dG)D gKn O|^]6y7MT-Q<\&XMHn܎?vƺNYDo}sy,Üˢt퓃gW/1uuU;,Dʉ[9V2$cn`[hä'&+:݈6!%eg]%n;4.q u ɲ>$jW, v/KÈ_.V#+p~!#Lʈkș#`Ȗ'XŲ:@H% Y$a_$3)֪L NC*ҙTѸ55;cg$Q9VԂC@.Pjc?;:"Duo/gcMs EPaAl¾I:<4lmDK }( sy*a.n*:&8+/0K. ^!/K$.ݴq,!tZUiGؖwY6Y(Ƥ|)M_ A D@oGj㘅Dl]Xx}OuZfxAALDJ5*`ִu媩X"ݑ&}2 WoQuoVM?ݞNKkºx[rJw\oź))3 9QE я'&Y"x" ߎÝI?a uxz`{sxp:88mYa}B TrA{Aͅbr >=={t'ӊq$@;j'hyR;g IHw!W&[{|xSEr])S|w eNNeOoUyj5lc-Fcvj2"VC9}K\#+a#,NP8WF 1N,3GkiƲyY0!+A?WN`&qĄDēA! -iy ${ 8U;sd́$ionl_Yz $R: _V'P5gq}T<ˇſ\0"dӡ82O ){hYUOgf7Ttr0QmD[rYF$u%I3%S6JN>)TKs:?ÕwM|basCpLNYysv? B^uP Z!ݍ; 9'jNpW~:<8PoR2U綐u!paV||V˳:j\b4VR%ga.<9<@xI v# C8'b+E}R^LcnOϰm :p>cYV+5D$5Jpсcdg؊gѭ=9س)+y.G@bZ&ʭJgU@:%йe\H1WŤ;{n%)ZiurrT% G_"Lht4s; ^tpTD[.DD3YujJshvp{v9j!!Z1KfVVCƒdkXºjNT6-&[/oXYsF6"begSN$OyǪ>>띨YߊK'cxi#4 \.Q3.MrVmFa+%C}G>)EpЌ;lf:i_moi_~=D&!YONB1!qv?Oll>g}9Q(Ya4F$ m'"W"; }Er1AڐM66ȳۯWxBc]m|h+ 1bF(3$bs3D|XVCcY@ ^Dq:?jƍ M֔bslۻ0k(R]ӧ܅ [-4.17h=eI{:N1dI/[gٕ_xSԠ|;sDžl# W@P1GjC4ũK&ug56FcC~x%,kp }zn-,$?z gB9gyEsoĵ=۬+:$-Q2wW i0 Sd`YhpMz&^E:#G/AְpdnmBZs#sP,v^h(]Qip5]oWӌw=.eI眱0傉84A.jOvXI᪆8a@ @ 4bFz`e.tUb"E.&z޿ǃ׃g?m$n cT7b/Ow",)1_E2=m׃Ye^31k. ǣ5!ڮi]T #kJ_N1t{Ϫpʂ((0n|ඍA &)7`1j/"pp#o/VCK *Y~(XCi4 ڝcpM{R "ϰB>X+3 @<>®?Ma7B5DScWTR>  AdÃtO1HS\G ١$22){!ܨf~Qݬ)u9*Ug W7g3ѱNl.jRqP-M1a(XxH p!qL `|E ZPC[j8$X](>/!.ad,n^ؗ[Mr  pU*s656bj*I52ՁQⲺiT2f%->ZyL \7%:6N$]UP~Ȼ_LxPږ}a/rp̭(`ADtIGf,ayoBȬo^gY.(t't/_\f[3g$;3{n:/'{Sukh19RbrM,Ԡë:I.x/֎j^ɃThFOZskLx-igWܦ8JfSF3*$LNRʃ >XGZ yO>mYӜ0#bʱ'1.\ԃ>Mez̋gǏuL+|Fv(߸ХvfQx_o>/ SJH4~ z#|W ֫'O6) Y5ROWS4(ބ[΋^9^ύ;듛 ɧmxQ^bp8P]WL/O:7ܵ\nY%LE.&7*J &z(Rkq}ksGw6;j-GˌHjJdvDh=jq݀$u]fIɳa]̬|>D7d~ Ԙ_ByZ׽y6{[<Ȧ\ l t(8%c|RLi1Yd!dgFL |'u_Ѹ,V7ɤ8 )]HdrjC2 j\S︰$`}%8`ڗŧ*++ؑE CݤeE^Ѭ "?ƱZ JϋR yzD 'CWHP(P޻wD[~@t"+Q,$P+&`h=eFiL[U 8ƻw/SHi1]_$ S'rj0)ԑIlɤr,_nA7̋{B_IE蓉.? Vc d VTiRoQ#°~*Y$+AVa!ɗomla "F K8|Xr=b7:=[q0 nm֐K0JQ+GnM?p[΄~.Q`Q\&&k|hH|&,J1?X ztb4&܃"Z,>8lY^ x~!Mӓc˜A'iJҝ+ЛߙMW p:=^%yqmKYx7!{謁X ?+vhPPWN&bETM2nm]B->U^|~A[MiyKidΧ͙Ęt1B1 aXf99Wno{L6?2NtE Ćn"/mjA[xglZLxd2z9 uX~*`DCzdmc%VC6$L8]2s[)Ggk8d\-Cc!.圿\Bpe.f ܒusע\u6t2FVI^t te34-㶫mWWhBAw!8K|[ >hX,EtTׯp5Y]ngAH~8J\!ѤMˈ{cgײ Y16L?ȢiMtĞ2Mgۚ (:6l6 ŨR|7@&TY|5loC()Nbj&t@t B)-ȇ6Ɨ\ڊ9?X1RB[i/n=s,_` x --3q8vv?WWiyQ&*'FUTu8昴 )+=Jr2i I0$㳹j }hd6 ,qdbǕiD4i\eH1/g)J\5/ɤgS.k2ܸ&EXnb> tGɌ *歪^i(غ2#Ȕ+;E5=3û豺}ۭ +ᬧBdP$԰ ՗$JYVrh!cc5Ĥǂ“e}E,Y}BS@E/M_kItdcQ=9UrI؇(3w}H6>%)Ivw2.s:US*h!;*MTD PQ :p4<iH fm|z -DHUVywIkv3+NghS'uX:ޤo@'7t*W|?,AʸQc$=C埓車u&EAo/ C~l4q,is!0ore.D#AU8b9͑CuYp=_\q9ϙ̮r׈6lַHot0JU>ꬑ= { tU+{G~p߻TK/WiqmDe 6zBeɅ%Zՙ_U2mTRA5ɺjpnK"?j(/@"dAoibUY*0vF}60!9%j-.z %'˦vC=r%):K_3YȤQr&gj:mlUB(nʇ,ֱ`6-Bvz&B韙Ph(PN=?duGlL{I\Slw-2I p5շ!۳i a{G <1-qI>-mRhZ%| >[{@d&~Tk P)zYuNER[o{IeUZl@^"sx*{h[lk4.bhf9KF8wD}C5շJmG[ڂ@2&ͯ)@TU7 jI֨7ݞ1ŷnhxY)spX؝v~cBg,:4:l] oYKJw?秭ԐlM}g|kR8.V8)r 8@~oҦN<ꆳ܌k ;Uܢ\9?/G"8 -? 8"dʔ3ف#|-Qd)".Ku/ԺP Vޞy_LnT$6'fJ_/diȸJԘkah쮹# W^>W?C*94$(<4m$VTb2B>Rej8>/'nt֏ћ\& n,OںLJhAhQq"cvNJxÃBzW|7|ĝn@y aoIgΌIc(- kl6y~u E<[ ..ë,'"*rwdvt`^- >P Ų.ki+V\mP/nQaV<{_jڍ&9~sHtcL(p 3h:5vZ9-2 6"i3k409Ů̺ƀEl vXp C?sM}iFc082ѣ3dF ^JbVI|0)il=KRdbd}D;1S8xȁgX.Dms% X^/lw&N,o9#ۊ8urJr> 1:ƷQ-͠eě^cٓ ưe7ee'pwGKr%$B' !.ز: 'e9*jm@a?\_"~G0(z˱遀@vݶGBwAXy-_Mq F߹DHGiR%;ۢh-CH?#B? }w#'rx$̠th̠pqU2wwa _4(WӒq:no98Au ::bxgV(+Haٗ9e0S9+,qe]YvC67Ul иgwצR|"Ϟ'[_;6YaC?~؟aJWr]'ވ F$&!)RMÓTzo¨/eOi` VDCc1% w{ˇ]rnẒvAcLvdeF;YX]L^`d}kϨޖ͙En{m9.Z~E)(,z[P|7-Sʌ^BnyrKX]O﵆Ѩ2/rRuGk*8_NVG=UZN'0{D}Ƒ U ykq=̤ߐʚ&#a,^P"2o擓D; 1fU/L7uff4s 8ֽ[8եZϭH]k ٭ĒƺjQf7BՋP2hJ5M7oZ..7:ϪHDLaZ/# RB:;/F`Kv\Dh!&AO28M dX>Êy~6ieiWktS7y3z;k/A} ZEprwB`'O汋AF/5>":ͱCt\{mg!U1& |AT&˲S#Բu)#qtzC+=js`}m9C[@W+"^|{0i8>`* _fP죽gmoQ(RhPZ6~Ħ_=FGS ska#kق5u*|Ǩ?$#Fy6uBZ}%f]t`[3y?\X50 ,EjlP5Q뜣XƀXeM7 n^#gMd| ~Nª==|I@B/SQ6@'c!O*a@UJ,l0l[!UͦVNP}C܆Z$ib[ȵ6kl#ڮU? no GT.F4nz ݧSXdO Y]J޽O!ΓYȪ>?G;/y=GqJE*0(~4*ɘXMwv@'YaQd6\;j 0'^PrI|Lv?lpMTvME6M*<ޞVzP ESѨUSEQEr Z'exSȁy8&TªO{Zi;y欏, YTslCUOLk6Fu 9?K>{r$Ee:ZZm,Ipn _ZoBП3dľXSٌt&_]Ea̩^fTb),h"{99cU E/R¤YeWCÐ|]*X]ҟ5v~M3z뵜3w4x|~K!Kn,e1ԹI ` |kDumuXxPf";F]m2C-4cs3޳RTk  ;yb}2I [P/''梔O^xGHTc;hs")ǰfjWm˻terG)ɪάɣǔu؝_w?% So({^le ib8}gwN,RQ;Z-=lBvgZ&C=4 >}Xï _WJ&, uPO ̡+օ2 dyѿY|uh9XϺU{7{"±i]L ۧA9f_bo >gۮ٭4 D9];K(֮dVt%/>Sai!Apj\frۻ,,Ve_hm_ϴ LdƓf4fFU8F:B6qS]]94\ӰsǂMRdAI>䞒%1u#`A v<ǔ <2 \q®$R\[i;Xg(X(rk` [11*0~l=u`{6znE/Wˠ[yj,Z7e*&c߼QE tœd!ok0YHKmGB|[Dm)Uupt,T4p4Ys҆QVr2TK6haErI8tD$orK&sr4K',q^x~@/=ⶵS;;V7m2wֆR5K?V7۸R+Qklڰf6}tnYy>eY讉[4mZpko].BL{p90v H-'2Էa_u%`$?C+F-Ey[ ,~6~:F]N4<ѣu+̟427Rx"LR) QSNW&vi#M7Jy-T'z؊n;-g"豒CO^Ȧ8e)YF#d{+M;应wt*G:m׊d7&tFݹfp}Jux\|K=QY)"l<f˒fE3W5?t-vM^b" (x]>id+?|^%|q)@{AϑoqR"-+Rb⤄h9QdNs_"KT5ϻ/cL<=N8O/?w5|&-r\~Q`lWpU:Z .Y |DK(㜝IN1ʷw7G=ҚD_ + p ڢqciMԹ5{3bZSz1P᪋=^E 2`ݝ`WPJs|c(vk4E\zy{49W,WmuXe;u0b }23~Tv`/hzUA7_9qp3!V%&\ml$zE; .c>܌5c|,ΊEvo?(ʍT JхO^?zDIM是 JT!D~GoPJ1OM&Bw´xxs+Gd۽jW_!'ISG 5ŭH= =ke8ЂO^%9,pq^SĻ YbiBNfr0>a2nhF#m`_of*tWA{q6DCCgo>> 0BT d{/;"Fjy~)ZH',ԇvCψ(zG,(O ò'BCFٌ">d<߈j&bE@?!+J$ u4~AjI0g2`)=_N2vaT9b'fmǸ{ǓD} ? p{>D&ޚ (}gJM[NyV#{3|-Z#PC/ u2׆A~"R]3\ɞ$]yY0pN~k+пrJN%;F[γ/$l6 i d1$z1Yľ@Q` d@bҕMmTw7P:5d*zLKn3YtfTs|þ/Rs9rYM8u#}{[hܥV yNtҒ-#+l|.+H šbǑ#o#'NpDY1w| dƉIi EͿJK6LX2Ytzş5F(,rhe9j8kҸ^yL[/czZewk!Q[,οB|5.Բb9xUsff = z[3U޹o]=N;UfSE$bI$|^L?K< pуp:¯ΉU_]ol\љt;X :i+r fG"Z+%#g9 %hݹ'3CH@na7]|Ir [b^v}ꮫ={buzeˎ-szaOlßĦ̜֞ ~\Ǥ_kbu1'cl)|k`,nd!m}Sob TEHNHxL;KriZwbNñ1x݇7:Uk6Iբ\:_^\vm6awU2$ƫh_bB9͙e7m iE`{Jq(i+k4I oĦ?L S8@py1s1gmИ$`=y VzO(\u~\'$Tpl'slh{K5 S6e_ ߞ^:ϯv ۉ~|}G;)ޡv 6)vɏgzvwg?F=G;?]ol={_P1pI-jsԨ>Lb,nyL%& K34Ҫh {=1ji 3>,BC~ƓKLaqay^5asp%~^dӺy؍t#O5@C>GRku7xm -L3m%$dH0tA'/!wM؈Vb AzWdm$0J?L:=ܩ`/q|whIcfH# #5{uբۘ5;hwduς7Z$[CW![lk8oqƌ3/ /A'HM]9B! a{[aC68,'"PE4`0t";4pޚU^5L=`"F럃U N款iaA|xc? NPaO!N r \vV͟5#;OA,'4s8QYbUǕ'1\m(6-JWZ[ B| ŒՈ~ Eڼd=ߥ df2U 3:E\-«j1:|^eZV(-$$Z/S1$poh4,ʦKφts J|yd@IوEb03m7愗@`ݜS+N˳=j}ꊪ,%X[]M4ݐ1 ̶uRgh~1TiE#vU>1u">\-g8Ջ䑌c|S5NF4oC"]=1IӮ":x̹?a_?FJ_^vN%h[De'Ч^IČ棋(i%a P}PǍo9.T1[ }X=1CrklY>aB :,2SDt6UlkfR18~e`ξp@ڡ`"TKC ? ϕKrqeOlk ^pkxj5hm(F<fւGI=3z!DuBR޵iC6̫Hpv CGf>gž _/$ DeܿyYɡI<5oW/5r]<3r]8+"FJ-ZٚJ:4? 4^pjOQ;=X Q\82QzDӊhR뵲0qD:"IiF½Кc |y@_FHq-S1'8̔ƍ#p\"hP,|@ICKchj'ky3З¶$j^ ێD\ $U7ӉT]71ozеkg5Ög4\멝S(b;(Ԭ:y;7)YzpE4 YuNNg'v6Vxۘ |`/n5;Y|z=915zw1 /\sZnZ]J`QgUhگbذq1_*rr]猻ήDެ=2x@oP>V^Ghy:/gSmJEvڴXps^tZkɸLDX</f(7{].}Y˵Oѥ_N] 閥qd(81?QO%z?\ro{dΝ'!mgc׳lQM=5x$fH:; " -n Gldi+q`Om_E<7E>6H)l1ih%2S8:R&G ,0>aF#A|3CJk+mI;4-{T"f}X!՝LS0 2Y%Fr{0 H~vεXTbVGo?/l|LTN)yi)ޣu]XB-{p%7D-Hʲ4.[!b./pvJ\En 7Bf:yq0֦j3IIxQբ$!/-5}̖yzF$īӝ#}@u>]01u@4>y:^ܘ/E} D+CWD*|w;_1zǑH46 'nVΐ+>G] CHM !.4F3Wfb+Ԅ4emruA#b2 Rױ婂O'j5K!]5p2B׭WB;W +2ĻWl XbOq_i#OnŢ]tvxq;O}h?@\q+9Ǝ'32#lf1*AV'ѐ>re`$p`pyCm@cÜ )1Up XT>ሕ1&ItN|YQz޽NG׭9ux3h6[YBJv2{TF%^]NCycyz\G۳ jK3z3@E*fLYK 34׆I /4>"nY]p>&ط?=l*|N[UoK^%k~ i BϑK?LI֒;kb bg/Y8Z0겚#t֓}V}xlhF͌c8+td$͟Dt_AܩPcΊ9)n0(C HĨ-BH!HL2aFSmF>jTȓ"-1&7ܴ9mXc*}Xw7U^-E6Ǩ2wFy J?@QB<Ff?.({~W*^}PM%,VgvU/0f`>C(|:-m?6{ +2c+$lL2&cMZ_n\UR6[vN#O9 `Բ?<)fM/A*Viz<-_)3\ey~1[oV\+fN n-ڟ`W7u`4Yk:|UTEfUo`QUZHߒy2@ ^=<bZAEfѓN k^k}R{z =l:֮171ѯܓ7{l r%:ኳZ"Ù17,`rWsk0M_0W  f CQCEB[5FوxbtPK)jLu](x0&}X=.`xk/^ w67"Y.7]|zmteEG;>E=bwVd5{`no42s(M'mֆŹ)5*5-@+_iе;AjCGoCXxeh%SyƳiJ" #~m.½=ĎGGx 4d1OCa-YàeN^!8ERND*W3,:^䓥߾>}ZJfY#|Tn{{t/z$dL;1#y ŷ<'|({A+Cqm^ғ 8gJ?)x"*q̜Y5̍ ?Gep쮚 AYU̖Y֍v:lE כّ2=~hi/%kY2D lٷsiˆ|ݬ=qJ?lAǻ3mνl# F-2 a2 m0xr܄<$xd?DV"Sz$eg?R~C6 #o= {9_^K1tvIH^ q\W6 077cیk<1cQ7d6Z/mT7z\OaC݌m^r9jYAAszqxxڻ}F!#3'翬 _8̬bڠ/-U/mu  %ꬺ /5LumZa<.2CzeuzQm0-W/˺j,_=f|oqum\aFւQ-ae=@puc0[ʵ\\]꿅 L=(,heZ[ 'ӯua3ypsW ٟ/ۢW''޷񜷝I0Wey -j)lcU7eLmPxӌJovّI1V~B.,Ka ha`,^(u*C4Iؠ7FAu z1kc60$|Vx!ߥ]|Rk  z=6dPu`+V4Fi3|Vv4ߒ5X:{b<'K^zڜ)+6 U7i)t<$n_Q9xJ^$|}U]+kyR:szqzpz2ћc%jxG'՗,&.Ē&uV-ϏESOM4a>&ww<뿊.~*#:ʳRGVgc<Hbm~j4;K Ro׸TNңtmL`)C,zbʉ>dT-:?|6~{29=Ws=g^1vs.o5_ ].e2~ 8ЪIKB۷1 IS[˦T/Vjx@%#kIJ:q٘ NΗ7G+Y`P]݆Ƽ#߆cv~@6<+lsWgsypY6M"e/i ]5b>&,ɲFčcFEUnoFO0&,A|}RO@yb'Gg~"Mi ńoI*k1~79b5O!dQ: Rô5)pZ6Ӡj/i3q% TV1!%ޫ.o EΧgCDΩH)%x$3GDWTA\&xvQ};qv 5J ֆ[a`Zm泊j:!B\=\z<|htUt/) ӴvvkZS+jȏнߥtkn`Ԭo{sZF^߰^v U}A/CSuM[؇f/l¨鎛^d~ڣ/|?.Y(`Z+k(y}şj?#PUjqfԺPcRByj1[o{.lgwr6 `Vs6@yS&3-6N5Jeu^7D-^Nޮel]o=5Ǭ$MZX)Mlqy;FS{w.l@BТ0CZ&WIV'r+Tmpmce )O?UՑrٿI[aD!0ӶشAu+t VEV k춃x'tdni=yIVAU̓`y0-U3@FJލJgqjfocMvOwfpIcԠzb^-KO=֌XdPHNeVqGWXO N(4)'o!Վy23°79v/F7Fd#-#F0{/E vL=zAr}efH%`kUX&nh-C,J=,m ` >~am5$'MvAije/F0pqk,4❀H"FChP IsZѮ>K,++% ?Qe<hm%g5!8kpł୹~`jq6 YQz-鵛N;CÄU u< .r!-ylLF䁃-*gzx3bٕX/n fI?DpHpx.8*^xsI*tuqlv4ݚftbk̶.Rt/glpo2sOnҢsLlvUv/* ybe~,t8ɗE4]Q,TI/ˆ|,wev52}H˴Y>b&dzvM2bY0iq>[tN"Ez|N1+WiĤ.K,}4!Me) iX"4H!$ήtv8LFq~3*~ў(! WQ>۟Mqȋd>8 j7LE'rϏUK987I/YQ*5=&eΦy2)_-xwͲEL_R5Ve*voΏ5 SFuV ̧80:H1KAu/Fuw)M&!41-o?CA4% q.GD'ckD]zv +,-hG?eevMŝ,U:S/"ڐt%E2m0Q6yw.g"S@Nw3m*7BwLZ ,{;iwyJ!7 E2+3̅DP"eʫiAQۃ&w wϷшVIġ(0ه(ՙ;.P+_9ΓCq}HF۝moEuLu6!.4>.[\㼖0ZA[ qcYLޜs,t,j Lx `dӏX'km?d rTNSVS(roٴ,ArOA?}osD\7Pnn:6VT,D+r te%M]z4|FCdR eŴ|gqU@?} #dr4)kNJj ؜2ဆEOa=։ZxD r7xpI.X:3IaNi L1B6KN :W7(vATWK0wafeDǫr>M"@D[j9.p(x0< t:ͨIS1M hURd T6ʍ.`y9@&kR׀X-9i2xtHbYhq~ƲD~31*j}<.X9iK\sN<.t`eXqT+}<)<ǒT^)yu:Rx7m@`0W(SKӎD^^N%4~c4DKf+CH7Ɋ "?8 ^<^;=we.`hBD釻1; Κa,W$7O// H7Fan PDT; FwӢ7w1=KjϢ 6o!-yEs:tkW:b?d9M_\gB/o/8 R^-ھ}'(b֭#},fx0]:8ZHXkCP@,Z1/=*+[!lu/`lR&,+Ie^=s k%;[4n! lyoNx  n|Qm5{sveq UU*jsSq0H_W6?L\olC?A73鴴#0XEؒIi=N9t XIpdŰrb }jnv#[)$I<1jgC+eojEx0hONH6dP}↉ N">nrfQtXԬO$v8 F '1 h._%kV^gUo7Bft<3&MrOr>g $œm* gr 0 !дBW)[qj8<)&D%A6C6k.yECo_۬3M}cg:?XQJ{X &v-!*u採 W0xz^xN4w E߿Igda'w|V.xhwzh8xpp^(M6/p35>9Rz_WNOy1ܽ#QS5i2l+2$-δ[|KN0W+YTĴr` OЭQDQWw6QTgX2@U],TxIaMA`QjvP"5l\N6P*XpAIT?kT~*B*"=[[P|)m=1 O+f5O|Ձ,  tV.2{! ˌM!Cޟ7̄"nPCiE&M ^80*<'knU|J%s:sn΂1jMrvBC"[]A t)8m|o*jrkD*\8~Dlq6H0TO~5fȸzNK1u5.j̰fTBOMNJ18_yXwG<6"u1|$U4{$7=n)vDͷhW-pxKyV#YXc]I;==1=ʢ>:ޣI]%kP{$rwA6Kz,n*5,qT%֯Fgs |IT`7!:-F#YcS>Z'9«*j?{#~y_Ҵ}g53Nђ|o 778ǰ~*7T养J~d4ѝUns|@l>Q/7!)؞dWڶ0Mfa@kul:8ܪg$ˍnFHZEGϩKPY3l@\ʏ9 T\&@,#柝5,=RܪZݫ.HexTQZݎ ˲7;Ͳ(pbZUr.v 4+ĩj+[gc#VMWhfs/KIUZ [h_ (* b7_YڃndW*2^|X%8z/>7JְU.QqePX-)F>̿>,YM/ l2M 8y1rHnl]FI[׸.# :&Ie0TBHUFGHe暽&8 N*vN{u}}00yz ؾف$PKt S0AuwCpmK\==_=@kgWc*9Mf2̐3 BVZɀ,nYrChu-qPФHv뼸ٛU<'-'Vu}|.-&A/{MKJH{[Eoī"-ī ]R B㉧j8F,Q>c~y)DӃ!6"5PJ^u4M&9Ym6F}c%\S ̷ͥwA%u$ՓtnaSg&"Zl)t[U]WȺROȩe(I F(T[\{SL !eHv_)5[񏁁o5ՕMrťڞ=m3.ECu>֙c@c7/o&3sJMV0 ׏o؈|CR0Tt/LQSTbg?s'pZSeФ?R$f!S2>Z~V9K=70z%Qd#,D#TjLK:w^Bk5Jkܱ@)t.zA9\HO|Ur] #oHf9RŷOjAtN"ԏEtݽ cAjxy\D3rEh8[cr'D2Rp]}ug(fQBi9 @ېihVs  ]H̓joDֺ " #bWcfO b{ oſk[ԔzYl1ǀaGB/Pa coJheRBņh[(=u>Kyժfӛ$VH$a!U N?+%wpOK4* DĉWIRyXe]"q9 Bٳ~k$e=P[BusnTU'K#SJWQZ)o읰R#A1٥AfҌjU~h\cIe4tm6rtf3%rdm65t?;4PXԢ:Ty-$85lOƧ6mFCiEYi u cQCH|SS ['u|qYZQ<ދWn㘙;<z iSԢ*c 39B֫_BzS;6 XCOX=%!f6JCyap8:? y$PLĸQsҦoGj<ȅxNR/k)ȝM3njH{lɱX)TsKgʹouamV_i5&{TcùjӮ!]{[@׵&`޾P E2~4(p ۪=:H7@*}?ד"+15eJ I11% @&-qѹ,Yς/(x  WoxMpҤ` 48 rf3TrCB{%׶ʦ. ,ƳVwT6 ~e(j ?SpO"q\+w}+ u9D;*Q:ډ7gQ7X[Rڲy]S2֔aa5QNЊrOFV`B72IQlj^<ʡI5|Oߩ\/DzcȽc0ɾee^ "VoW4 j7WM" kiPVa^i _+[L섢eքOqDP } 2vnNj%HݪRl!(qOôWgə|Y&!W>9GدyWBf:X%ZzlV*ҿ}!mP*tTE0T85pb']=*?BV'PɕWd8b8iWjM^~3(\Іr#oQaI|(:7+p3_Zc{!<3+(6:Ճ<_X ` &sIcV=Y{n*\\&Mz֠&+;|N+54iMpTmÍiz${ a7,,w ߍޱMKI,%+n?˜!ζ"` av F UP6SGpӭV\MJ4cڋlpdbH-EIK֫ Vը/j*0j)u%Sjpݫ~JU]}b \X. !M]X6Tc_#tLxbf)Ͻ6GE 겠dM'r^+ςgV^6sHD-jF}a">eD; PǼ\o6I[02Rci ;MbOe֠r $) Jlm_4{*sQuFTK͞*T[dD†BKdyMpCnAqM&@)jxK֭kթd mM86D}lprŀ\30>}R|0sC;T `S+ bhw z]ӧ.O,umWG]92Xj@mF,@fGdm#t[ZKmmsZeS//q/Q>Y2w/EԾ $<™Dyav+ߘmC=ڰ T(-ʚ vL0q.[hX M.e<+!aOx4aHG ]梸x=K_ue_h=!W~esQ3NA%+DZ"%fc`=,4  зC5/i󁾝~$U{}! 0~< \ 8ҢܟIUdsj=ͽDA 7Kh[0kf$|EMCa F|Ӱv_ƽ2XF}6V_T#;P7$*}.aJ7]aḧ́tUGX;׃ݰ!zRC'emm|BLoI g+E8.+,}sV- HEl l~RG2i͐Q3imd7_ՈjeMVO~ ``W,(k(VŒ8Ԧ`^u^TֺrMMVs]诲Z)PnߦoZcΙY3ӊ`܎@R|W&﹣~D5=;A/釆H\DpIz|:'˴O >H[g#=J-/&93c@ܔ_5E6{.z[ii&3 Aݥ|bwH%vqX7ۇx{8|wt;*2/Yی낌 px;d Ϟ&4%ml۳\"lM iYjXKWrq>jp JE-"CckҟpA5(\ٴ_zHtTi{3V8iEƌg 4W4l.OR[SYȄA^ m_r; $\$-;ܢK2,wYد25TJѰ^lkhU|^AoPL T9z'G-w-rdrneZ<0/iQ4my(b*b]1!at+z.bBIChM53̑6r7V*];vK.h~\ϥ˹Tj!ԷzsjO(==Hʸ~X6$V8B7yQhLie\Mֵ:mEr!Z/铣_V%Jf.Ŵ"(Iԗk]c]_ @M!ߞ?8pTsFexKOǕ)[ԶIlڒt=@=iM,gYaj `px?<;" ŝd,'*mcTsE( m`nuΎaQy<gdk8hGȎ [AvnP0qZd$KV1! l-`_R5O8Tap Q^54S+> urc(wc~cn_c;Iaa7)= y5>cԥTF]s]A&<5{<:>6GB.|x CY4aE]0GLUx1hTw@y/<.H.Y. ː&wZoZzj߶GWjز,;XT3p H[5,K wLvݒ*M8'i ]4R>cRސy뻷.I<%%_ 1!oT cwP:]@auD!M% ۜ"v8wkQEzZ{ X=P;AZ]IR_[nlc'O)zL+-⽚M]зaIn^ˣK2s^9>]gI:4Tql# S|h~f4&::@4"Ӝ=6F seV:__1Ø^n_}*#3uBgZ<{qrKɰ=w!U5\Up]H505U"8ljQ.+H*[9[-( .bLrc{Xdz UWvTV\B˻kI!Ty>s  6Dv?m%$$׎]h~Y2fMԋDdō@б3jꒆsv}0j֌=v\RhA1h *v'i7TygٖET]8/=0ov^ayW=#gF9FѨ4&u* } pbOO2Ft 6|fsiU\(7T'p0րND码+udyBC)'+b{DFBx򄓞>`2 %M`kxPɓ'CKZL"t<]^K:Nd'He(z8 .1 J&GBӰPTT^EUR4]gp*dF7H9.ficB[$WMPyWxM2w5{CƎ(4MĊ|rvU)ɝǿ3ykr#ݮPlE9ey\¸&⃔$@|DnAv./IF72T7ȉEEH*SB2w2Ŷ!Cr"W]OB@Dg? Mx, _\.}jQ BNB4l4dk%\L@_N2Jju#]YBD㈝:L piCH[t I*hN :řVYT%>}r.XiJ'.i*OʁZk(I_YG/2غDC|};QAZ@>tvg ֆCF1J2ֽDcDꔰ}&)wtx\c{A2bS(jغUIu85QAesE%ŵjî%.hIrһѫI]$]o8a{KuՓ|Imn"0dZBhӏuD'}`Uc?Ua%|U-:;р>pI;,=*ewywh%_,/H|"]ˈວ%V$mm}/<*+\7`ULnEdeةbDV]^#Ԧƍ.b ^b-n|IXj/ou&yozLUeuY c >Z"@T 0u$1ce> j i>=Н;сw( YDj}dMKϻ%X|PjHV {Lx͍UR@%Z8]P_!\ m9VǯE$+z2ԋ,+j>;Aۣ@7ʯEBoohIaHajcwpflP &Ҷ_zӸB-P6ok#߉1E2Łeky};+b`Wn+TkiXhY0/A㟊)Jh@\1m?W'xF (B7)gA]~">MjHӸq␎=!(-iAm!cJHa[A4Z\6N]ʯ:- eXjJ,P+k>H/#jƖCkk:*Qܓb0ha:r?mZ;61?xYf#ۨIWR=!{'W'G{'mec+;+ 'p-j4JI/ѰXVl_JXRQQT0.on'WIkfֆ*t|){s/"z^4f^OS3C FA-E/q!~2;tiB5Ze|;8&Zxm~}8j= R]EZ {$Ƭ+r@x<pX&d<: D W+bTH]7Szَxʷ\<?* o3N/HHN<>cےpP]vM? `ނhݰAn=9?>I>$p#=%"Do?j*Aǘϗsahgt`4pxtzӡ4 3΄JowMM&m+g v@'GG;GwNj^x΋|i9 z{8bkowTub3" 3=zwxp EnE6];TlKj{O??B0UcEBhw'FM2(ӏU,QE,}W(8`什%oSbӒ;s 9:<; !f:;ǯO`l~'Iҷ`ݽoЦXGtBaz=}yCk7䎎~F9wYNtB(`d4uQ[)" S2Xq uѳ(Ċ'"Ze-/v]:J3*߰prR;S}\ꮧ&}7H;w4ъv#Z7doH+ʖE# uw x)[E3 g1M}08eqbp1g%wF9Rs0E!"*; ҝO +kݒ1O̴íYEnM22SѲz e QmB4P4lᑍF#ݾБU.\{3[@߰|,P#sl^*EUNnJ|cqJӆ c3 . { 1>lK-"K8X@:Tex F g8~V_KH׋? E_XX?ՑƩ*݃("Cӧf)-LYG:ϣIɻ:[iD#&]6B[)o.gC,{vKE/\]@Ej d30v,[ef(SɻKI 9\kaBU b Ew?$'`h>$_M+JIBaZjW AooO:#Ⱥ G1T=BqQh Y9\zȘ%| S5cM#[bl`1sx9"G';{3NqH <>}C0NX~ts9J*\T쨺jUԫDJ c_C?V+Nevbhz2TMߝ xTSTm|y*̺(7I*MaZEj{In Gr(@<׺Aw(1Utu5$tBhAeٌ`CAO>Ɩ#mIqxqejvVlg2s^dR3./ng`va옐uRS^|ԞA襓lUGn:Пd)iN}ojEJʌ+0,OcLVRb}wdWƟ:rN2HK-\"N ?Za":?͖1Yy~%ݑe3WUL>GN)IA`<>RCNuX p:u[6Ls?X,EAPCy@mJZV.O{T[ku]lnh@+TTreh͝궱SmhGbi?8ArekHihOvGxL.d:plYCۚ'&O=~c'8"[ƊRCk!˛x2oO0D2G?pͻ`Tc(#pU$X "q[f)^]%J r pAVlKj*>&d~.KH81fv9CͲ!d β1awX@lg5"gYU=T|Q̳r/P6V'ftŔLlUĊa;D~hR<04a$w>I)PaXYn2\ՠ]<ԥ LJ Q;h9,hSADw<Ϝ2:W?ČJlB"T[7R v'[/_\vօ'Wv5GPPig4y%6QLCn #:5Iܔ&3xt*EUt!Yόkb]޷^&BK! N9:15My!a&=|^qca mٛXv0R'U-n)n kMj@?@Y/~ yFRf|JqIQkAh~]%+.z 2g5?FZW> |ٳg• ( Hw$}Cya 4v4l힮 /_][RPh5Yd/F{#2EoBSK?JdgT$P /b,#]N"gd rs/\dے"+Y`zh'kCY өU V}iu!Z:a@>9پ,g ItJ >}P 5y0aJ+iz".Z75)Qk +OL[5v4Q'{|t]W/&|%nJgryq61E~u5M./]fQ'U?5/SNo < '3Vp^{qVa9\ ]i% :')Iw}V-U֙rl0u(VSQ)p%-Sk ,Y>FW YPӻ~`++zP3{utt}H OZYn]3Wx/q=lh"er#Yږ^Tf\&XR>k;uD !%Pc^i]ܢqg+C<ۻjOJwX"\(('& kX5& !'=I\P?*+O)mW86f>]o%HեZCeS;mP8-gBx v:'_D@Ou„}j5 !et;>hﲨUN(K:ONt3$`*W=a,TxX* HulRr)5(;~ݳ2{MɾE6S O.R",k=Oْ~w:Ru7G?  5j{m.!lM )dsLjQY k> ,/҉:ud[lVS%̈́i%H8¤iϘn+.TcK0j 5T_7IHٙwyS=l5zZ7XÛn3&GCv^_=*8d)$e_]p;"+ ^}[PvBQ]2m$mz)$leQN#rG1ZE^(̐BeڹWu wv 髰7}*(hM؍5-\ u(M*r:<0;Q$4^N%YF+]lHUP&!1KڵƊX*β vqQ0l#[#TOl DPkӻMg35B{snL@K f. WD"J љ|WV2M0OURq9\7Fy2`!N?#m׳3Ta&1/8 7y{$]QEL"_pLhy\CLZ>FFr 沖ͩu 9(_(KF sRw깮eVףic{uj$证pF3=ׅoaƢ`;`熊 p6,ThMucDYRM89is,$kRFvo({zLPFG_A[ 'B!JkW佡_1"0HPy38 vEU+ $*PIFUvE56}&VSqM%,wPPE;A6EC濹}CSOA?ySOAޯ.{?n_ Us"gߧ-Xj'v1y'm3!p*[,c/}.*4z+q4vesZ5 h!z~[Y}e9(65nbR,+am|9DtojZk7K;PkvQsJ "U~`&g=2OX^TTz\3 (zrѕUB0bD꼵o/Oy@ أP[{mZqծtkuc]O! Y2 iR #&1yQR 1ѐZRmoŜ+glƐ؟ ȪTχGidz${՟}dkT&ܮ[!W5+'qwWS;)ξ{k 9fiHP0PQOp6gɛpƯiQ=*|I*Ώdott!YX-4x$>i囜z PG? PK.r`żȀpkbDh`tW]Xi%4ǻ$H N\E-S8xY$Hdٵm`D*-7;=HX7Rܨ[PY~u 3w⮷D孛eI|?1uZH]=?ضlXޯ驡SBw{%S#[H3At_./EXKMw)u|F_ :A'GOe) tMpthJ@_!Iyiܴ|6 hw:+i e5Y8$[>"lhLzi -i&}`Dͬo4*|\57kp.ڢ"A,~=ZϏHOkao .Q;gndZD1HҺ(*tNҲ08{jn1(jQ8۞g[=;#I}TK XչAIʘZp Qo%ƄrX?[oe$ɚpI8oH[)Y3 Y¦!V[pXPυ551!'= 3&'J(?{"El#6AteÕpHס@L7v/!Cu$P~$+f$c6* `to0R-%XI yub~G :%~FKoe%HqdK}QqraT$ϝ%hQp_Rc28EA\70*$BuY&tҝO+CE?)b$O2)i-vd0V!# kzv_}wx25z <6WjA!Sw=Γ^\Ȓ0 &B0ըϲɳ`'-T(3N})OaUX%IKcWoUkBxr#=FSg2mқ}oQ^ng>ş>mOS ڢ0-G[0.ċvm5(5HD̅Hijv:e3BR h*rf?f!.Cæ-2ϤL$N|8)&U #g\KK6h>j)F2?Bvm`ZulLB՝$يd@oc8X$vLfYdxwC)9jw=Tʛ6)הR <n𹿮6cm>Dg>,6 ^ C{=Q~Ct,a@>E:(h.xmyG$~s&^خdYýI yΚ0jSVf4["ltKز|.rs1n(Hh|7?\Fe /gڛ-ot=Ǵ庵&1^O{(޷K,WWiQj+S ebKte/JVz~#v/}K j8Q,AFEZĒqm<;B7 !)-w0k;HkfJ~>vOFNaTn'֫pC~I.GH-_d_#8 b?{ /`d&$\E?v<^1^oơԸ;V i'hN#-,%MtH^r)4jY׺ߧw `.4{Xޫ#CnI  Xz>eGfAY`wVRD"L_/ٶ g0D'I=cGAG s,ܸ緸g ~Nʧ5D Nҿ͜TQcx˦A17\:5;y bֆx񌂺x.b8ɗA" <EtJy'0brh3lr_y 1,V{NViE2Wj|Ntޢ<@ݮW" VNj1qF:|Vo;?No0p/kԹ\]pP +"x@дbY-'UoЁuDy-pYNb$"xI-Os1u^qOd[b_iϸUJp \e^+KɛJI1u-ԅ Ki[Q^sO!kZh-^C0=';=r'?:.i@ңK/?mOr -Cnn铧^HFaisQݔx㛦޾y \r +}VhƵTm+dz_a_r1)&IAvi 5Jrީ^BU(=.-R"}wrg=Rmz&&YIc7{N^* O~ ~{uA4HxCkjr(K;*+xK L LbLg*;|}t)U+/wB(cBH=(p>I6u[ HWB&fr9]l ] k,V,qaO&޸{.ޓW_k Ճ\// 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. /** * Creates an element named |elementName| containing the content |text|. * @param {string} elementName Name of the new element to be created. * @param {string} text Text to be contained in the new element. * @param {Object} opt_attributes Optional attribute dictionary for the element. * @return {HTMLElement} The newly created HTML element. */ function createElementFromText(elementName, text, opt_attributes) { var element = document.createElement(elementName); element.appendChild(document.createTextNode(text)); if (opt_attributes) { for (var key in opt_attributes) element.setAttribute(key, opt_attributes[key]); } return element; } /** * Creates an element with |tagName| containing the content |dict|. * @param {string} elementName Name of the new element to be created. * @param {Object} dict Dictionary to be contained in the new * element. * @return {HTMLElement} The newly created HTML element. */ function createElementFromDictionary(elementName, dict) { var element = document.createElement(elementName); for (var key in dict) { element.appendChild(document.createTextNode(key + ': ' + dict[key])); element.appendChild(document.createElement('br')); } return element; } // 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. /** * Handles the Extension ID -> SyncStatus tab for syncfs-internals. */ var ExtensionStatuses = (function() { 'use strict'; var ExtensionStatuses = {}; /** * Get initial map of extension statuses (pending batch sync, enabled and * disabled). */ function getExtensionStatuses() { chrome.send('getExtensionStatuses'); } // TODO(calvinlo): Move to helper file so it doesn't need to be duplicated. /** * Creates an element named |elementName| containing the content |text|. * @param {string} elementName Name of the new element to be created. * @param {string} text Text to be contained in the new element. * @return {HTMLElement} The newly created HTML element. */ function createElementFromText(elementName, text) { var element = document.createElement(elementName); element.appendChild(document.createTextNode(text)); return element; } /** * Handles callback from onGetExtensionStatuses. * @param {Array} list of dictionaries containing 'extensionName', * 'extensionID, 'status'. */ ExtensionStatuses.onGetExtensionStatuses = function(extensionStatuses) { var itemContainer = $('extension-entries'); itemContainer.textContent = ''; for (var i = 0; i < extensionStatuses.length; i++) { var originEntry = extensionStatuses[i]; var tr = document.createElement('tr'); tr.appendChild(createElementFromText('td', originEntry.extensionName)); tr.appendChild(createElementFromText('td', originEntry.extensionID)); tr.appendChild(createElementFromText('td', originEntry.status)); itemContainer.appendChild(tr); } }; function main() { getExtensionStatuses(); $('refresh-extensions-statuses') .addEventListener('click', getExtensionStatuses); } document.addEventListener('DOMContentLoaded', main); return ExtensionStatuses; })(); // 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. /** * WebUI to monitor File Metadata per Extension ID. */ var FileMetadata = (function() { 'use strict'; var FileMetadata = {}; /** * Gets extension data so the select drop down can be filled. */ function getExtensions() { chrome.send('getExtensions'); } /** * Renders result of getFileMetadata as a table. * @param {Array} list of dictionaries containing 'extensionName', * 'extensionID', 'status'. */ FileMetadata.onGetExtensions = function(extensionStatuses) { var select = $('extensions-select'); // Record existing drop down extension ID. If it's still there after the // refresh then keep it as the selected value. var oldSelectedExtension = getSelectedExtensionId(); select.textContent = ''; for (var i = 0; i < extensionStatuses.length; i++) { var originEntry = extensionStatuses[i]; var tr = document.createElement('tr'); var title = originEntry.extensionName + ' [' + originEntry.status + ']'; select.options.add(new Option(title, originEntry.extensionID)); // If option was the previously only selected, make it selected again. if (originEntry.extensionID != oldSelectedExtension) continue; select.options[select.options.length - 1].selected = true; } // After drop down has been loaded with options, file metadata can be loaded getFileMetadata(); }; /** * @return {string} extension ID that's currently selected in drop down box. */ function getSelectedExtensionId() { var dropDown = $('extensions-select').options; if (dropDown.selectedIndex >= 0) return dropDown[dropDown.selectedIndex].value; return null; } /** * Get File Metadata depending on which extension is selected from the drop * down if any. */ function getFileMetadata() { var dropDown = $('extensions-select'); if (dropDown.options.length === 0) { $('file-metadata-header').textContent = ''; $('file-metadata-entries').textContent = 'No file metadata available.'; return; } var selectedExtensionId = getSelectedExtensionId(); chrome.send('getFileMetadata', [selectedExtensionId]); } /** * Renders result of getFileMetadata as a table. */ FileMetadata.onGetFileMetadata = function(fileMetadataMap) { var header = $('file-metadata-header'); // Only draw the header if it hasn't been drawn yet if (header.children.length === 0) { var tr = document.createElement('tr'); tr.appendChild(createElementFromText('td', 'Type')); tr.appendChild(createElementFromText('td', 'Status')); tr.appendChild(createElementFromText('td', 'Path', {width: '250px'})); tr.appendChild(createElementFromText('td', 'Details')); header.appendChild(tr); } // Add row entries. var itemContainer = $('file-metadata-entries'); itemContainer.textContent = ''; for (var i = 0; i < fileMetadataMap.length; i++) { var metadatEntry = fileMetadataMap[i]; var tr = document.createElement('tr'); tr.appendChild(createFileIconCell(metadatEntry.type)); tr.appendChild(createElementFromText('td', metadatEntry.status)); tr.appendChild(createElementFromText('td', metadatEntry.path)); tr.appendChild(createElementFromDictionary('td', metadatEntry.details)); itemContainer.appendChild(tr); } }; /** * @param {string} file type string. * @return {HTMLElement} TD with file or folder icon depending on type. */ function createFileIconCell(type) { var img = document.createElement('div'); var lowerType = type.toLowerCase(); if (lowerType == 'file') { img.style.content = cr.icon.getImage('chrome://theme/IDR_DEFAULT_FAVICON'); } else if (lowerType == 'folder') { img.style.content = cr.icon.getImage('chrome://theme/IDR_FOLDER_CLOSED'); img.className = 'folder-image'; } var imgWrapper = document.createElement('div'); imgWrapper.appendChild(img); var td = document.createElement('td'); td.className = 'file-icon-cell'; td.appendChild(imgWrapper); td.appendChild(document.createTextNode(type)); return td; } function main() { getExtensions(); $('refresh-metadata-button').addEventListener('click', getExtensions); $('extensions-select').addEventListener('change', getFileMetadata); } document.addEventListener('DOMContentLoaded', main); return FileMetadata; })(); // 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. /** * Handles DumpDatabase tab for syncfs-internals. */ var DumpDatabase = (function() { 'use strict'; var DumpDatabase = {}; /** * Get the database dump. */ function getDatabaseDump() { chrome.send('getDatabaseDump'); } /** * Creates an element named |elementName| containing the content |text|. * @param {string} elementName Name of the new element to be created. * @param {string} text Text to be contained in the new element. * @return {HTMLElement} The newly created HTML element. */ function createElementFromText(elementName, text) { var element = document.createElement(elementName); element.appendChild(document.createTextNode(text)); return element; } /** * Creates a table by filling |header| and |body|. * @param {HTMLElement} div The outer container of the table to be renderered. * @param {HTMLElement} header The table header element to be fillied by * this function. * @param {HTMLElement} body The table body element to be filled by this * function. * @param {Array} databaseDump List of dictionaries for the database dump. * The first element must have metadata of the entry. * The remaining elements must be dictionaries for the database dump, * which can be iterated using the 'keys' fields given by the first * element. */ function createDatabaseDumpTable(div, header, body, databaseDump) { var metadata = databaseDump.shift(); div.appendChild(createElementFromText('h3', metadata['title'])); var tr = document.createElement('tr'); for (var i = 0; i < metadata.keys.length; ++i) tr.appendChild(createElementFromText('td', metadata.keys[i])); header.appendChild(tr); for (var i = 0; i < databaseDump.length; i++) { var entry = databaseDump[i]; var tr = document.createElement('tr'); for (var k = 0; k < metadata.keys.length; ++k) tr.appendChild(createElementFromText('td', entry[metadata.keys[k]])); body.appendChild(tr); } } /** * Handles callback from onGetDatabaseDump. * @param {Array} databaseDump List of lists for the database dump. */ DumpDatabase.onGetDatabaseDump = function(databaseDump) { var placeholder = $('dump-database-placeholder'); placeholder.innerHTML = ''; for (var i = 0; i < databaseDump.length; ++i) { var div = document.createElement('div'); var table = document.createElement('table'); var header = document.createElement('thead'); var body = document.createElement('tbody'); createDatabaseDumpTable(div, header, body, databaseDump[i]); table.appendChild(header); table.appendChild(body); div.appendChild(table); placeholder.appendChild(div); } }; function main() { getDatabaseDump(); $('refresh-database-dump').addEventListener('click', getDatabaseDump); } document.addEventListener('DOMContentLoaded', main); return DumpDatabase; })(); Sync File System Internals Sync Service Task Log Extension Statuses File Metadata Database Dump
Service Status N/A
Notification Source N/A

Debug Log

Time Log Event

Task Log

Duration Task Result Details
Extension Name ID Sync Status
// 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. /** * WebUI to monitor the Sync File System Service. */ var SyncService = (function() { 'use strict'; var SyncService = {}; /** * Request Sync Service Status. */ function getServiceStatus() { chrome.send('getServiceStatus'); } /** * Called when service status is initially retrieved or updated via events. * @param {string} Service status enum as a string. */ SyncService.onGetServiceStatus = function(statusString) { $('service-status').textContent = statusString; }; /** * Request Google Drive Notification Source. e.g. XMPP or polling. */ function getNotificationSource() { chrome.send('getNotificationSource'); } /** * Handles callback from getNotificationSource. * @param {string} Notification source as a string. */ SyncService.onGetNotificationSource = function(sourceString) { $('notification-source').textContent = sourceString; }; // Keeps track of the last log event seen so it's not reprinted. var lastLogEventId = -1; /** * Request debug log. */ function getLog() { chrome.send('getLog', [lastLogEventId]); } /** * Clear old logs. */ function clearLogs() { chrome.send('clearLogs'); $('log-entries').innerHTML = ''; } /** * Handles callback from getUpdateLog. * @param {Array} list List of dictionaries containing 'id', 'time', 'logEvent'. */ SyncService.onGetLog = function(logEntries) { var itemContainer = $('log-entries'); for (var i = 0; i < logEntries.length; i++) { var logEntry = logEntries[i]; var tr = document.createElement('tr'); var error = /ERROR/.test(logEntry.logEvent) ? ' error' : ''; tr.appendChild( createElementFromText('td', logEntry.time, {'class': 'log-time'})); tr.appendChild(createElementFromText( 'td', logEntry.logEvent, {'class': 'log-event' + error})); itemContainer.appendChild(tr); lastLogEventId = logEntry.id; } }; /** * Get initial sync service values and set listeners to get updated values. */ function main() { cr.ui.decorate('tabbox', cr.ui.TabBox); $('clear-log-button').addEventListener('click', clearLogs); getServiceStatus(); getNotificationSource(); // TODO: Look for a way to push entries to the page when necessary. window.setInterval(getLog, 1000); } document.addEventListener('DOMContentLoaded', main); return SyncService; })(); // 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 TaskLog = (function() { 'use strict'; var nextTaskLogSeq = 1; var TaskLog = {}; function observeTaskLog() { chrome.send('observeTaskLog'); } /** * Handles per-task log event. * @param {Object} taskLog a dictionary containing 'duration', * 'task_description', 'result_description' and 'details'. */ TaskLog.onTaskLogRecorded = function(taskLog) { var details = document.createElement('td'); details.classList.add('task-log-details'); var label = document.createElement('label'); details.appendChild(label); var collapseCheck = document.createElement('input'); collapseCheck.setAttribute('type', 'checkbox'); collapseCheck.classList.add('task-log-collapse-check'); label.appendChild(collapseCheck); var ul = document.createElement('ul'); for (var i = 0; i < taskLog.details.length; ++i) ul.appendChild(createElementFromText('li', taskLog.details[i])); label.appendChild(ul); var tr = document.createElement('tr'); tr.appendChild(createElementFromText( 'td', taskLog.duration, {'class': 'task-log-duration'})); tr.appendChild(createElementFromText( 'td', taskLog.task_description, {'class': 'task-log-description'})); tr.appendChild(createElementFromText( 'td', taskLog.result_description, {'class': 'task-log-result'})); tr.appendChild(details); $('task-log-entries').appendChild(tr); }; /** * Get initial sync service values and set listeners to get updated values. */ function main() { observeTaskLog(); } document.addEventListener('DOMContentLoaded', main); return TaskLog; })(); QN1 yFt[NEj&*ۇgcx W-/o7LJ;ṍͅEGhֆvbe=>Ebd"iUʍ!BKc,G̥ص0S -DO6L< ;,= B{6hK',Ъ(oV5s_:M2XY.6+6,GMdHɣ\ $(؄Hyis4m QGv)$@#bldiN0D{qHP#\h>7ώ֛S"Ŀ AK73fWK~ts.~8KCSS&m*gB g4!d ŎJɢ_x~Xj舞6Ґhc /|5vJKS}bKb?-e228jBS꠾ (q1&mciټ"~GQ77ȴ8VmO9b6Ai>+WOU:Iև׎loJ5@9$V~&ƈeûc0ˌEe2mlRBY0hѬ'd,^˄ "#qhro燫36A9H9z.fv}p !1 |rr\v uhN!g!%N&њ\O{Dx0({Gư͟EGEXϏ>=BQeGFpV9a+OWlIZue_0wbl3otӢPZ:qδrL(4(n!OByYᳶO; &]@N8*uZ\$%(bh 8|vVz3'> rߩV,QK+&k!. x׹[IaG6ܚWJjB5˾5zD1p{L[]v&Gͷ<q0Tpt+?TWh5Oz4M/0^1Ae'tY*Ha&sISxoZGs&d[UV5eYWwxPp?uB#3폿 ߺ/H͕aNk4' H1a]rAk6*l< q;KB"rR/oz0rs9m9gsT= ۸6 4^U|Mz.d,*<%ҨW_p].LhQ<n_%\ Ymo6_+(GCd@7 % $+w$%Y%ŰK,ƻ;f,rp0?gjlؽ_.SAS2_(~ttpx>& 'JHћ$R!z#rD)ct812 HBE E2yT(-A]_`m!#IAЌ bYd &z{~\p$aL!(J̜\2~T$ӂfl BOF~ɓ &YL c&@bYٍhtңH┲!Ef-1@@) i}4Ћ`0tR 9rӱ o6FlՙT) hSXtdX,T3j眚TҒ_v9S^zuu7J\~o'sB)Ca襝$c()L#SkG*D9Zu-78xҤxH4_z3&) 49 7 "~Z0kT} 1FXX~NNL7]Ynʆk r}Ht{lfU2DNBi;%/ JYCfC#FcS^B&Qt-!Ehf[l'zEG`:W(9;sYs$DLZ+)w 27lݔ. quYy'>,BLjF>p 9SJ,ote'sԛ'4_'Zn5ᔲ!4܃xzUJ֝TV;,pL&6Oic=~YXZ8^.!S_c_)U{gѣp1760:9#/yjj`-`#cz 9Il ½C$tjxJ!d@|$F㇣ҦW&#_O[/[oL{~4X?v.*TBWp(.݉^jcu t"w5$,h_R D60*'tݦmrK(V gȀEcJ(_[(f!z^PPi4_ŠֽT8̷=IĜ,"ϥ2kw]z&X|AQ\Zڠr4FO^[Qfm[\_kL6RbC,V?#t"¶'])M^[ 0pg? oj&#w8i(a>`8IمNvu;\is[\q~µl樅nFvC o벃;Եܧo^僐ٳG I်;?}ږۺx^.V̓7J" >|p~wi [Jj+Uk,A7;Kf%=D Yۙ?S;;I)2Ӹ|Y=叆Arz_@? $N>2hR{gFeSft[w,#")oX>O售 M%El!._\l'is:ɄT,G6z]ۙuǻISP"d"m{E"ewtf+މhgbQTݽ88-Us)/ED^fQ@T&y/(3"S&U9dJ eN2Y8 (ؔ&Xi EJ3^ a9Sr~v|XFpVSxo[ABl*íi%trQq.Xҳ\84зKPI9\^Η__%9˙dqMyFbN<9"p%%R2#H _+F @D0O@B@Ɠ 8C %WEs#20LӌGj1os2a%"^eK., &Y n8<.jaLYVeK4ekq֒^B-K:i(.ϕ~5L,\\ Kgcs{=Ԡ`rK<WM4; ;/a;iG0 .<{'>T{!YezbXx :ip] Ap#CR^=c%h}%> "j( rd 8cd6#g}7 Y2 GGd7pa&pˊ1@fc#GG4fY,)INhR O,2:?Dhm ȐĨj"fiEO9,J^6XL;ߧ>mK,d&Sz{kgE([~ny!&ckHeXeItgYqS[UWWX}P1IyCKЇx^dtQGzDcH>FU 7V%i~1uawۯeZZ!РfjAhhsCل[@ϵUpz_1f'֞jRTEKo|qAu ~ؚEstRCi ٢,w 4lEΧ-%6Go 98" caoad0@8-7tJ\s2\OW}hO}3^N?MG|7vΘe !ȇlHHA8QItx𘇚 y?SKM;q*s(c^ߜcl <]C׎D\uHEski\zWaI_X9Z6i/oXhq<@Π V6t1;{-ϰ~Ր17gIe dKx;cqe= ?FLj~[-nʪnqקXRKġ"gW+EnxȵFt:Byq;<Kw&Q}=.ZBeSMmcJɺ Jr>38*dPWH~szUP)a/F xzH@?0V +a#VnF[WY6iþ6)&ԝzm,l0eӐhYšy]wWD(V8i26BnP0FԵMYR^m6J6X~A:ij> 92g`'x,ȃVkKjU8~B,[b;j&>P}l{Qc3noKSrTC_2>7lAmmZ`2<(^?,-7v)rY.>+,Uzm,KpqC..]1 FxY28?8&c$/I oI窡t_aُ`ՉpL#dF=$ 161eDN-G M3p""oql1PLq%9j `Y!g:SYJ.;nSR~'jˋJq"5_o[)-ݰ軁+ Z@\0LJ7[M].^. =(za{^D3}ծIvfaڿD|u gXmqɱQ\/S-9>q)ɂW{fЮIGEu'Ի:aR*t',m{\ UW5Ral^S@2I vǼOhHb2-gx =4wR˜fUkkm> &Uoy;*eILdDҽVٽ"NS@J)J^@} <~߇ ''g?YEt㿸0S 2{Q1lS|ܼz|pKH(GgYۺ-mﳮ!a[h^yDg``uØ"lO;N޽1 |Mt5I`8 K;// 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. // This features file defines extension APIs implemented under src/chrome. // See chrome/common/extensions/api/_features.md to understand this file, as // well as feature.h, simple_feature.h, and feature_provider.h. // // Note that specifying "web_page", "blessed_web_page", or "all" as a context // type will require manually updating chrome/renderer/resources/dispatcher.cc. { "accessibilityFeatures": [{ "dependencies": ["permission:accessibilityFeatures.modify"], "contexts": ["blessed_extension"] }, { "dependencies": ["permission:accessibilityFeatures.read"], "contexts": ["blessed_extension"] }], "accessibilityPrivate": { "dependencies": ["permission:accessibilityPrivate"], "contexts": ["blessed_extension"] }, "accessibilityPrivate.onTwoFingerTouchStart": { "channel": "stable", "contexts": ["blessed_extension"], "dependencies": [], "extension_types": ["platform_app"], "session_types": ["kiosk"], "whitelist": [ "E703483CEF33DEC18B4B6DD84B5C776FB9182BDB", // http://crbug.com/717501 "A3BC37E2148AC4E99BE4B16AF9D42DD1E592BBBE", // http://crbug.com/717501 "1C93BD3CF875F4A73C0B2A163BB8FBDA8B8B3D80", // http://crbug.com/717501 "307E96539209F95A1A8740C713E6998A73657D96", // http://crbug.com/717501 "4F25792AF1AA7483936DE29C07806F203C7170A0", // http://crbug.com/717501 "BD8781D757D830FC2E85470A1B6E8A718B7EE0D9", // http://crbug.com/717501 "4AC2B6C63C6480D150DFDA13E4A5956EB1D0DDBB", // http://crbug.com/717501 "81986D4F846CEDDDB962643FA501D1780DD441BB" // http://crbug.com/717501 ] }, "accessibilityPrivate.onTwoFingerTouchStop": { "channel": "stable", "contexts": ["blessed_extension"], "dependencies": [], "extension_types": ["platform_app"], "session_types": ["kiosk"], "whitelist": [ "E703483CEF33DEC18B4B6DD84B5C776FB9182BDB", // http://crbug.com/717501 "A3BC37E2148AC4E99BE4B16AF9D42DD1E592BBBE", // http://crbug.com/717501 "1C93BD3CF875F4A73C0B2A163BB8FBDA8B8B3D80", // http://crbug.com/717501 "307E96539209F95A1A8740C713E6998A73657D96", // http://crbug.com/717501 "4F25792AF1AA7483936DE29C07806F203C7170A0", // http://crbug.com/717501 "BD8781D757D830FC2E85470A1B6E8A718B7EE0D9", // http://crbug.com/717501 "4AC2B6C63C6480D150DFDA13E4A5956EB1D0DDBB", // http://crbug.com/717501 "81986D4F846CEDDDB962643FA501D1780DD441BB" // http://crbug.com/717501 ] }, "action": { "dependencies": ["manifest:action"], "contexts": ["blessed_extension"] }, "activityLogPrivate": { "dependencies": ["permission:activityLogPrivate"], "contexts": ["blessed_extension"] }, "app": { "blacklist": [ "2FC374607C2DF285634B67C64A2E356C607091C3", // Quickoffice "3727DD3E564B6055387425027AD74C58784ACC15", // Quickoffice internal "12E618C3C6E97495AAECF2AC12DEB082353241C6", // QO component extension "06BE211D5F014BAB34BC22D9DDA09C63A81D828E", // Official xkb extension "F94EE6AB36D6C6588670B2B01EB65212D9C64E33", // Open source xkb extension "B9EF10DDFEA11EF77873CC5009809E5037FC4C7A" // Google input tools ], "channel": "stable", "extension_types": ["hosted_app", "extension", "legacy_packaged_app"], "contexts": [ "blessed_extension", "unblessed_extension", "content_script", "web_page", "blessed_web_page" ], // Any webpage can use the app API. "matches": [""] }, "appviewTag": { "internal": true, "dependencies": ["permission:appview"], "contexts": ["blessed_extension"] }, "autofillPrivate": [{ "dependencies": ["permission:autofillPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "automationInternal": { "internal": true, "dependencies": ["manifest:automation"], "contexts": ["blessed_extension"] }, "automation": { "dependencies": ["manifest:automation"], "contexts": ["blessed_extension"] }, "autotestPrivate": { "dependencies": ["permission:autotestPrivate"], "contexts": ["blessed_extension"] }, "bookmarkManagerPrivate": [{ "dependencies": ["permission:bookmarkManagerPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bookmarks/*" ] }], "bookmarks": [{ "dependencies": ["permission:bookmarks"], "contexts": ["blessed_extension"], "default_parent": true }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bookmarks/*" ] }], "bookmarks.export": [{ "whitelist": [ "D5736E4B5CF695CB93A2FB57E4FDC6E5AFAB6FE2", // http://crbug.com/312900 "D57DE394F36DC1C3220E7604C575D29C51A6C495", // http://crbug.com/319444 "3F65507A3B39259B38C8173C6FFA3D12DF64CCE9" // http://crbug.com/371562 ] }, { "channel": "stable", "contexts": ["webui"], "dependencies": [], "matches": [ "chrome://bookmarks/*" ] }], "bookmarks.import": [{ "whitelist": [ "D5736E4B5CF695CB93A2FB57E4FDC6E5AFAB6FE2", // http://crbug.com/312900 "D57DE394F36DC1C3220E7604C575D29C51A6C495", // http://crbug.com/319444 "3F65507A3B39259B38C8173C6FFA3D12DF64CCE9" // http://crbug.com/371562 ] }, { "channel": "stable", "contexts": ["webui"], "dependencies": [], "matches": [ "chrome://bookmarks/*" ] }], "brailleDisplayPrivate": { "dependencies": ["permission:brailleDisplayPrivate"], "contexts": ["blessed_extension"] }, "browser": { "dependencies": ["permission:browser"], "contexts": ["blessed_extension"] }, "browserAction": { "dependencies": ["manifest:browser_action"], "contexts": ["blessed_extension"] }, // This API is whitelisted on stable and should not be enabled for a wider // audience without resolving security issues raised in API proposal and // review (https://codereview.chromium.org/25305002). "browserAction.openPopup": [{ "channel": "dev", "dependencies": ["manifest:browser_action"], "contexts": ["blessed_extension"] }, { "channel": "stable", "dependencies": ["manifest:browser_action"], "whitelist": [ "63ED55E43214C211F82122ED56407FF1A807F2A3", // Dev // The extensions below here only use openPopup on a user action, // so are safe, and can be removed when the whitelist on that // capability is lifted. See crbug.com/436489 for context. "A4577D8C2AF4CF26F40CBCA83FFA4251D6F6C8F8", // http://crbug.com/497301 "A8208CCC87F8261AFAEB6B85D5E8D47372DDEA6B", // http://crbug.com/497301 "EFCF5358672FEE04789FD2EC3638A67ADEDB6C8C" // http://crbug.com/514696 ], "contexts": ["blessed_extension"] }], "browsingData": { "dependencies": ["permission:browsingData"], "contexts": ["blessed_extension"] }, "cast.channel": { "dependencies": ["permission:cast"], "contexts": ["blessed_extension"] }, "cast.streaming.rtpStream": { "dependencies": ["permission:cast.streaming"], "contexts": ["blessed_extension"] }, "cast.streaming.receiverSession": { "dependencies": ["permission:cast.streaming"], "contexts": ["blessed_extension"] }, "cast.streaming.session": { "dependencies": ["permission:cast.streaming"], "contexts": ["blessed_extension"] }, "cast.streaming.udpTransport": { "dependencies": ["permission:cast.streaming"], "contexts": ["blessed_extension"] }, "certificateProvider": { "dependencies": ["permission:certificateProvider"], "contexts": ["blessed_extension"] }, "certificateProviderInternal": { "internal": true, "dependencies": ["permission:certificateProvider"], "contexts": ["blessed_extension"] }, "chromeosInfoPrivate": [{ "dependencies": ["permission:chromeosInfoPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://version/*" ], "platforms": ["chromeos"] }], "chromeWebViewInternal": [{ "internal": true, "dependencies": ["permission:webview"], "contexts": ["blessed_extension"] }, { "internal": true, "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://chrome-signin/*", "chrome://media-router/*", "chrome://mobilesetup/*", "chrome://oobe/*" ] }], "cloudPrintPrivate": { "dependencies": ["permission:cloudPrintPrivate"], "contexts": ["blessed_extension"] }, "commandLinePrivate": { "dependencies": ["permission:commandLinePrivate"], "contexts": ["blessed_extension"] }, "commands": { "dependencies": ["manifest:commands"], "contexts": ["blessed_extension"] }, "contentSettings": { "dependencies": ["permission:contentSettings"], "contexts": ["blessed_extension"] }, "contextMenus": { "dependencies": ["permission:contextMenus"], "contexts": ["blessed_extension"] }, "contextMenusInternal": { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, "cookies": { "dependencies": ["permission:cookies"], "contexts": ["blessed_extension"] }, "cryptotokenPrivate": { "dependencies": ["permission:cryptotokenPrivate"], "contexts": ["blessed_extension"] }, "dashboardPrivate": [{ "channel": "stable", "contexts": ["blessed_web_page", "web_page"], "matches": ["https://chrome.google.com/*"] }, { "channel": "stable", "contexts": ["blessed_extension"], "whitelist": [ "B44D08FD98F1523ED5837D78D0A606EA9D6206E5" // Web Store ] }], "dataReductionProxy": { "dependencies": ["permission:dataReductionProxy"], "contexts": ["blessed_extension"] }, "debugger": { "dependencies": ["permission:debugger"], "contexts": ["blessed_extension"] }, "declarativeContent": { "dependencies": ["permission:declarativeContent"], "contexts": ["blessed_extension"] }, "desktopCapture": [{ "dependencies": ["permission:desktopCapture"], "contexts": ["blessed_extension"] }, { "dependencies": ["permission:desktopCapturePrivate"], "whitelist": [ "63ED55E43214C211F82122ED56407FF1A807F2A3", // Media Router Dev "226CF815E39A363090A1E547D53063472B8279FA" // Media Router Stable ], "contexts": ["blessed_extension"] }], "developerPrivate": [{ "dependencies": ["permission:developerPrivate", "permission:management"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://extensions/*", "chrome://extensions-frame/*", "chrome://chrome/extensions/*" ] }], // All devtools APIs are implemented by hand, so don't compile them. "devtools.inspectedWindow": { "nocompile": true, "dependencies": ["manifest:devtools_page"], "contexts": ["blessed_extension"] }, "devtools.network": { "nocompile": true, "dependencies": ["manifest:devtools_page"], "contexts": ["blessed_extension"] }, "devtools.panels": { "nocompile": true, "dependencies": ["manifest:devtools_page"], "contexts": ["blessed_extension"] }, "dial": { "dependencies": ["permission:dial"], "contexts": ["blessed_extension"] }, "downloads": { "dependencies": ["permission:downloads"], "contexts": ["blessed_extension"] }, "downloadsInternal": { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, "easyUnlockPrivate": { "dependencies": ["permission:easyUnlockPrivate"], "contexts": ["blessed_extension"] }, "echoPrivate": { "dependencies": ["permission:echoPrivate"], "contexts": ["blessed_extension"] }, "enterprise.deviceAttributes": { "dependencies": ["permission:enterprise.deviceAttributes"], "contexts": ["blessed_extension"] }, "enterprise.platformKeys": { "dependencies": ["permission:enterprise.platformKeys"], "contexts": ["blessed_extension"] }, "enterprise.platformKeysInternal": { "dependencies": ["permission:enterprise.platformKeys"], "internal": true, "contexts": ["blessed_extension"] }, "enterprise.platformKeysPrivate": { "dependencies": ["permission:enterprise.platformKeysPrivate"], "contexts": ["blessed_extension"] }, "experienceSamplingPrivate": { "dependencies": ["permission:experienceSamplingPrivate"], "contexts": ["blessed_extension"] }, "experimental.devtools.audits": { "nocompile": true, "dependencies": ["permission:experimental", "manifest:devtools_page"], "contexts": ["blessed_extension"] }, "experimental.devtools.console": { "nocompile": true, "dependencies": ["permission:experimental", "manifest:devtools_page"], "contexts": ["blessed_extension"] }, "extension": { "channel": "stable", "extension_types": ["extension", "legacy_packaged_app"], "contexts": ["blessed_extension"] }, "extension.getURL": { "contexts": ["blessed_extension", "unblessed_extension", "content_script", "extension_service_worker"] }, "extension.getViews": [ { "channel": "stable", "contexts": ["blessed_extension"], "extension_types": ["extension", "legacy_packaged_app"] }, { // TODO(yoz): Eliminate this usage. "channel": "stable", "contexts": ["blessed_extension"], "extension_types": ["platform_app"], "whitelist": [ "A948368FC53BE437A55FEB414106E207925482F5" // File manager ] } ], "extension.inIncognitoContext": { "contexts": ["blessed_extension", "unblessed_extension", "content_script"] }, "extension.lastError": { "contexts": ["blessed_extension", "unblessed_extension", "content_script"] }, "extension.onRequest": { "contexts": ["blessed_extension", "unblessed_extension", "content_script"] }, "extension.sendRequest": { "contexts": ["blessed_extension", "unblessed_extension", "content_script"] }, "extensionOptionsInternal": [{ "internal": true, "contexts": ["blessed_extension"], "dependencies": ["permission:embeddedExtensionOptions"] }, { "internal": true, "channel": "stable", "contexts": ["webui"], "matches": ["chrome://extensions-frame/*", "chrome://extensions/*"] }], // This is not a real API, only here for documentation purposes. // See http://crbug.com/275944 for background. "extensionsManifestTypes": { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, "fileBrowserHandler": { "dependencies": ["permission:fileBrowserHandler"], "contexts": ["blessed_extension"] }, "fileBrowserHandlerInternal": { "internal": true, "dependencies": ["permission:fileBrowserHandler"], "contexts": ["blessed_extension"] }, "screenlockPrivate": { "dependencies": ["permission:screenlockPrivate"], "extension_types": ["platform_app"], "contexts": ["blessed_extension", "unblessed_extension"] }, "fileManagerPrivate": { "dependencies": ["permission:fileManagerPrivate"], "contexts": ["blessed_extension"] }, "fileManagerPrivateInternal": { "internal": true, "dependencies": ["permission:fileManagerPrivate"], "contexts": ["blessed_extension"] }, "fileSystemProvider": { "dependencies": ["permission:fileSystemProvider"], "contexts": ["blessed_extension"] }, "fileSystemProviderInternal": { "internal": true, "dependencies": ["permission:fileSystemProvider"], "contexts": ["blessed_extension"] }, "firstRunPrivate": { "dependencies": ["permission:firstRunPrivate"], "contexts": ["blessed_extension"] }, "fontSettings": { "dependencies": ["permission:fontSettings"], "contexts": ["blessed_extension"] }, "gcm": { "dependencies": ["permission:gcm"], "contexts": ["blessed_extension"] }, "history": { "dependencies": ["permission:history"], "contexts": ["blessed_extension"] }, "i18n": { "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"], "contexts": ["blessed_extension", "unblessed_extension", "content_script", "lock_screen_extension"] }, "identity": { "dependencies": ["permission:identity"], "contexts": ["blessed_extension"] }, "identity.getAccounts": { "channel": "dev", "dependencies": ["permission:identity"], "contexts": ["blessed_extension"] }, "identityPrivate": { "dependencies": ["permission:identityPrivate"], "contexts": ["blessed_extension"] }, "idltest": { "dependencies": ["permission:idltest"], "contexts": ["blessed_extension"] }, "inlineInstallPrivate": { "dependencies": ["permission:inlineInstallPrivate"], "contexts": ["blessed_extension"] }, "input.ime": { "dependencies": ["permission:input"], "contexts": ["blessed_extension"] }, "inputMethodPrivate": [{ "dependencies": ["permission:inputMethodPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "instanceID": { "dependencies": ["permission:gcm"], "contexts": ["blessed_extension"] }, "languageSettingsPrivate": [{ "dependencies": ["permission:languageSettingsPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "launcherSearchProvider": { "dependencies": ["permission:launcherSearchProvider"], "contexts": ["blessed_extension"] }, "webcamPrivate": { "dependencies": ["permission:webcamPrivate"], "contexts": ["blessed_extension"] }, // This is not a real API, only here for documentation purposes. // See http://crbug.com/275944 for background. "manifestTypes": { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, "mediaGalleries": { "dependencies": ["permission:mediaGalleries"], "contexts": ["blessed_extension"] }, "mediaPlayerPrivate": { "dependencies": ["permission:mediaPlayerPrivate"], "contexts": ["blessed_extension"] }, "mdns": { "dependencies": ["permission:mdns"], "contexts": ["blessed_extension"] }, "mimeHandlerViewGuestInternal": { "internal": true, "contexts": "all", "channel": "stable", "matches": [""] }, "musicManagerPrivate": { "dependencies": ["permission:musicManagerPrivate"], "contexts": ["blessed_extension"] }, "networking.castPrivate": { "channel": "stable", "contexts": ["blessed_extension"], "dependencies": ["permission:networking.castPrivate"] }, "notifications": { "dependencies": ["permission:notifications"], "contexts": ["blessed_extension"] }, "omnibox": { "dependencies": ["manifest:omnibox"], "contexts": ["blessed_extension"] }, "pageAction": { "dependencies": ["manifest:page_action"], "contexts": ["blessed_extension"] }, "pageCapture": { "dependencies": ["permission:pageCapture"], "contexts": ["blessed_extension"] }, "passwordsPrivate": [{ "dependencies": ["permission:passwordsPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "permissions": { "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"], "contexts": ["blessed_extension"] }, "platformKeys": { "dependencies": ["permission:platformKeys"], "contexts": ["blessed_extension"] }, "platformKeysInternal": [{ "dependencies": ["permission:platformKeys"], "internal": true, "contexts": ["blessed_extension"] },{ "dependencies": ["permission:enterprise.platformKeys"], "internal": true, "contexts": ["blessed_extension"] }], "preferencesPrivate": { "dependencies": ["permission:preferencesPrivate"], "contexts": ["blessed_extension"] }, "privacy": { "dependencies": ["permission:privacy"], "contexts": ["blessed_extension"] }, "processes": { "dependencies": ["permission:processes"], "contexts": ["blessed_extension"] }, "proxy": { "dependencies": ["permission:proxy"], "contexts": ["blessed_extension"] }, "imageWriterPrivate": { "dependencies": ["permission:imageWriterPrivate"], "contexts": ["blessed_extension"] }, "quickUnlockPrivate": { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ], "platforms": ["chromeos"] }, "resourcesPrivate": [{ "dependencies": ["permission:resourcesPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://print/*" ] }], "rtcPrivate": { "dependencies": ["permission:rtcPrivate"], "contexts": ["blessed_extension"] }, "sessions": { "dependencies": ["permission:sessions"], "contexts": ["blessed_extension"] }, "settingsPrivate": [{ "dependencies": ["permission:settingsPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "signedInDevices": { "dependencies": ["permission:signedInDevices"], "contexts": ["blessed_extension"] }, "streamsPrivate": { "dependencies": ["permission:streamsPrivate"], "contexts": ["blessed_extension"] }, "syncFileSystem": { "dependencies": ["permission:syncFileSystem"], "contexts": ["blessed_extension"] }, "systemIndicator": { "dependencies": ["manifest:system_indicator"], "contexts": ["blessed_extension"] }, "systemPrivate": { "dependencies": ["permission:systemPrivate"], "contexts": ["blessed_extension"] }, "tabCapture": { "dependencies": ["permission:tabCapture"], "contexts": ["blessed_extension"] }, "tabs": [{ "channel": "stable", "extension_types": ["extension", "legacy_packaged_app"], "contexts": ["blessed_extension", "extension_service_worker"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bookmarks/*" ] }], "terminalPrivate": { "dependencies": ["permission:terminalPrivate"], "contexts": ["blessed_extension"] }, "topSites": { "dependencies": ["permission:topSites"], "contexts": ["blessed_extension"] }, "tts": { "dependencies": ["permission:tts"], "contexts": ["blessed_extension"] }, "ttsEngine": { "dependencies": ["permission:ttsEngine"], "contexts": ["blessed_extension"] }, "usersPrivate": [{ "dependencies": ["permission:usersPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "virtualKeyboardPrivate": { "dependencies": ["permission:virtualKeyboardPrivate"], "contexts": ["blessed_extension"] }, "wallpaper": { "dependencies": ["permission:wallpaper"], "contexts": ["blessed_extension"] }, "wallpaperPrivate": { "dependencies": ["permission:wallpaperPrivate"], "contexts": ["blessed_extension"] }, "webNavigation": { "dependencies": ["permission:webNavigation"], "contexts": ["blessed_extension", "extension_service_worker"] }, "webrtcAudioPrivate": { "dependencies": ["permission:webrtcAudioPrivate"], "contexts": ["blessed_extension"] }, "webrtcDesktopCapturePrivate": { "dependencies": ["permission:webrtcDesktopCapturePrivate"], "contexts": ["blessed_extension"] }, "webrtcLoggingPrivate": { "dependencies": ["permission:webrtcLoggingPrivate"], "contexts": ["blessed_extension"] }, "webrtcLoggingPrivate.getLogsDirectory": { "component_extensions_auto_granted": false, "whitelist": [ // Extension used for API test. "ADFA45434ABA2F1A4647E673F53FF37F8F6047A3", "4F25792AF1AA7483936DE29C07806F203C7170A0", // http://crbug.com/775961 "BD8781D757D830FC2E85470A1B6E8A718B7EE0D9", // http://crbug.com/775961 "4AC2B6C63C6480D150DFDA13E4A5956EB1D0DDBB", // http://crbug.com/775961 "81986D4F846CEDDDB962643FA501D1780DD441BB" // http://crbug.com/775961 ] }, "webstore": { // Hosted apps can use the webstore API from within a blessed context. "channel": "stable", // Set extension_types to 'all' to prevent webstore from being filtered. // Technically, webstore is not in apps or extensions, but it is currently // displayed on /extensions/webstore and /apps/webstore. The "contexts" // restriction effectively restricts this to hosted apps and webpages. "extension_types": "all", "contexts": ["blessed_web_page", "web_page"], // Any webpage can use the webstore API. "matches": [""] }, "webstorePrivate": { "dependencies": ["permission:webstorePrivate"], // NOTE: even though this is only used by the webstore hosted app, which // normally would mean blessed_web_page, component hosted apps are actually // given the blessed_extension denomination. Confusing. "contexts": ["blessed_extension"] }, "webstoreWidgetPrivate": { "dependencies": ["permission:webstoreWidgetPrivate"], "contexts": ["blessed_extension"] }, "webviewTag": { "internal": true, "channel": "stable", "dependencies": ["permission:webview"], "contexts": ["blessed_extension"] }, "windows": [{ "dependencies": ["api:tabs"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bookmarks/*" ] }] } tpuZC(Q؎esT3&JQ:KéHmTUJ*-`8 )176h7O'qn<}a?^O (lm$th%jU\&ɅΤzQG!0Cp „ǭB椋v RQSPDcׂa%ERsm;^EM [Nb~0x;*}jl_^6)h&8–KI~m0Ϲc82`,SNRr܅砟Q#ɋ6gnٌx$t }7jE4K|K].I4. bNi%i]44n Jp;j~ $%9DRZ#zȶiA(ܯUc&/Kr Er ]`c8#ޞZe)P~^$pvWl+j]8ŮӅ_M? s 8[ee]mg1-8|T](>cM=אܴ0ݰ+mX\b_J o HUY@NƪTpýb.Zohͬ=y@BTvr&:Y+Q3&l͊d.<xjp0WHܐ߇jf"BfV(Vˑ]N1=3#}*3# 6l# x}==Iw ى5WUUd+w3m$XjʿWd5r(k Ԁ^"o]ޱڕU*Am${.{2eUfIi7Rjh?wī}O*%GX z l͡Ð"H9Hn{ "_}4Gޙv+0u4e"Cb솚}ܦGLBϯ{:da$Z^BI#Xb%d,r!hڇD*Ɖj/FMKTNJ MZ&8Y״{{6Q)mdLlZM:nM7bЧ 緸2[X*i1ejGn7&cV[<-WdK® IߧE?h u?2cWE:r 0P,ǘfJH7˰YSsESi22r6 ]صܷUqBWd( Oe_eJu "xXzVj\ZZMQb,c3cڣtDPT/~/s') "W`'xBL{Dl@D4|/݌XV=o/PѨALW!:gk̅;Km<8]q!Xbv⁇-PXY K EyO?⋱`]v&Zt^mZɅ ;6sRvUx= 9ۚN|JG ) R=eyКv߾ ~쨇X) j<-m7G%׷m{M:~*-\aoL@4NAbׯ ,VpEɎ8.|Yl}}Fqvu$KZqH+.'=pÂI7Hxz -(南bʤ0֒ QU;-l,"#CTqV8Sx%ڭ ),V|uLk]Lj+ (˷_a=e%~jf9T0:*pV S5ogMGWmfI(@aFuAV#nGП G8 XXi<*lώgwea_ͳf|'Pi*&cIU9Us Ԓz9Y"+(z$2'R--nsbtQ%t@Lq8diww8R=)Xa嶭a"=UUxV-,i8ŏí0kqhQHx4Jx w*prE&rE#L(W`+=h`SgT9.9#uUYIګaR7z+UhʑY $"ta?kZJ7]+!x2`Yp/~gC1|0NF`^G}r{nYe [䫫 :C׏-mسȭT˭>Ypv;OWf|yj[}b Xf,I"!!- EXq^ i5sDd;\+hpmH0c&r?6=l2(m ݣ-y/BȠBj$k#HTP{D"Da?hzP4(Z<-Ir9IsA}a>ʦILd50̏o1=:J爗N05Exրe6kgkoKk([V+7:hnk3Q2ZZLaC%`H oIB/TY(2sI,'8ۨk>Ӎ;;1~Mg +r i+fV)= ۙd`R|Fϫo\C6}PdN\4ؚN=$6+* F<3-o3KKQz_,C:@Polڧ$ʶxY"0nX4hPJԆѰ= UL>H RU˨*u0(`-G 9H"NA77TFmJdZh\!2r<9֤`Xj%țVdEA, (p Їv=g4>nG+U@d#c F;c=[W mIn}҃e2MG7jmlT 8=ʨc!l``=㽦7gyinh`CAg$Bi6X%acz02גnV,)<A= 0$Ur{CJWӘo0(P.`√g3˓qQ,I %q(a#He9&qArvj )3;Qڍ&֡bByI{!v\&q͉U-uiUܬӪ0}Y^8S9z5o.JwB@u)DΝBbH2Ly0#2?i8Dwg@aiihCNzu]̔`~hnl7WDpRnt1_Ys א`?_4Hw7J{ *6hRmN1OxYbڄ_kˡzh&Y?W=6dϼ&Z]5/ʯ3fpÕp,%~. CV70>=}ux2Yⅶ縷O %FINv1,>)@RfU5 yfJeࣀ!8caNpGeҗx2jKWՄ&|W>m0?|JTrqvvK=Eo \dyc!ί]㘷~N9\n:d7yc亯tm8Omۋ76k՜]u~[Cm[*Cvo>{͕۫`FY!(q@[g=kfU;B˿.R܊2E/G˕Z,vsj1Mҏ\}X4aUޤ:( bF&̖e&s"KޓOuSUs+J ܋iE7wn_\^ЪMJ.}fAT:\oG;gU Qew  6O.'t0ήI&uDbM oa"Tx_u2z nChe0йWbPeu ihue!C୼10_)9 9N&0衵hb|zG8ÍbQ?s=|_q+ʺ} UZH @Xe۱P>7Lt򑅅khJu^w=t*e\*uLcm:iӦކ4b7‘,ػ e"Uu\= \{x҄{|?/to ͨ[.&6^A5C"@z@X]-@5rz!!́z3~|GZ.y2*$R5aKC}+r-d(,>45/+P/vذ̽lѨ.3ꌋXˡOlwh&'&spZZDm0I"l-1@W.+UBb%:8G-#w' -<,he]fӴ*Z֪ciK.$K,cEwJhIoj_JװZAaarbY]zl/m{5rx)ni,{۬L_ɊzJ{lf[L" & ? x<*U\L2t-ݫm .xXdy [" $VgilnL!K#-F+!*eJ 0Ke3;n`m$zHIXR/iL\ \<.tZ.hbۭse+NRof,`սXݑ9gE4"gݳ'[B~.rA"s W!7ҹjnS5 Ho^g#ojHGNs+MTD: LV$2*07͑m5LN3a JKgQHbkq68N1'zғ*\<KTgo' $"mirZn1ei6bumQJ?'ZbJICokӰzq4`#qrn7)O(ч>SVKb6&CK,rm٠gYg aʭ #>ݼ%W,r)|Qy\eK?¢ᴦQIz.Dg?i19[' [pCjMyX2NZuc Rb~5L46xj)LJ1rlBGͫ.lB0,M8#5[b_㌰3SZvV\fu`=4q9l/"kFN-$?EP NIk߼y^J5JQnJ7IgTO$D#huhŐ9cB(,wY<֌0_[ԇ18~7PP_f`ge9B>D0 tX4T$y:+1S7h[6Ww2%ݭj4 rFj ۋL:aT (KXBT W50-wXO "C Z\$@œM;ffSTO%f$4zia^: ᶻ4D-|>Dks˨Nnwe) 7%)}X@AhWݠywk}k) o0!.Ab N|LZ78:-0 mka*6cf;ᶹWGalJML`<lEdqܬz,]6˷;\I[5-ZFGogEYnM+aT-q }2hZ3"S L5[pj^}kDځAh:d@!b,Sq2L,[<qO(G!l30v4Y2Qo=0Z{#knj11#`"ł) kFZ5t;UzuϤT=<9 fXr}#1  Usٓ詐'ܢ;;t"eo9D/q+nĉCv%3̲׭aq,AKB ,,exVE-Lm&ThF-Nw>.C| - ?5E=ʒNΚ6e~cW=!/4!Oܻ]KIvG6@6>*hnZuX`R娏X4xԑ#K(gcCWOrY#TUcG8ND*VpLi@Hc)@ҀoO2V 3,}3 _YI!0@Le1Yf:3-S~yć 硑P8U|j-= ^/M2wY)XL\M&B"q;Vz^Ow魒8'ҩ0W {(3-RNQ(ɘZGj5p ]pDP\8RgtA.h#|~VB;KsYܳƐJLL&TV,6~Ro%P8,'?Ax辸,a :͊n|P%]G&DQmG@h[J -UPnOWU Vx!Xϲ6^KƟo<ң(jơEܹ7lgE6n%j٣wXs֊c?޽1iwZu[[Z 0 WUmM\by(8+9 6mmuPX#H& n:w$O?}0Y1-H_Oygu6&@ {^ܟH`{Yyѭo3YvRM9ʤ˕Sr*AN/d)lX=. rAK9[HٜXq}Yt!z[~_/[kAw/V4[eO~!Ex4˫R ~=ظWVW`"i[p%Vz.eU~9Ƹnbt U gD,M3l8Qa= 3 3.g=iF$uuV_Rvh꣑uOA̟*cԦjkE&|' 'Z?+H4=)[& \ɂj&ݷZ~ hFמ-ZF>nj<\X J/!4Sx%jR - ʆbLi VhZ9>NC`Pxqa QI!# %Eh&K_;|A7$<5DDPa0Dw "=4ADy9sy) '^EKɋsE_ToQ.nu@2/! O;|(gmlc&!mmvr#4XQqaEPD%q,'>wצ?XI_ Q= Y @|6#*b9#n1ǩQKx`לH*,c'[kƥ  >_U6b l#p|o/'O*3mԬfDncu-e.qr_kS\;K5* ~tkd2۪պJ}kaXtY6JP'󐘷Xla.q}};AUs״`LԛcUĪToc[c.St2),,x#H y`Dm4jLA8og :Wb:U?+X8bQRĪ:iöO@K\ m ,9W|G.`l7{"#*ʤyeux9RIN ך'25GSQLt5éz\. Y#?eKVj.r [\Ȟ̐/dҬsN1@kAeJ ^N3M>Іl:&긽.Ax$'˭#\UJeb2?l+n7뮸_"ɁlV]<4gUqURtń/ hτXu.\| p&JӏpF lwΜgof |riгJ` "R7,Oџ ѷCM@O?-fRVV\Oe|&V*Lcfa~.h|:"e_t"DRɏ*$H!?R?qn\N*ejW\Tqy+4cף +  =gC]`DGxg[}(fj-&BYPM%>x~[E H⨳Ds;F!EC~O-%cufb^lbE뗊|B%yBTJrB]+1HEn ߃l[Ba7] mx,s\=jM(k:f6tJS2B>b%~0'3&wOL?AG+ca4jLfl/ir1eAvoy)v{, .jzj9b.S׎90_ށ&~y32 줼}X ZhFh`<)@80"#B80¸qG< ޤ2:5cχ?_U${׃°Eփa d`~N#nX`{leAנxzP>A a}M;JPgYI`e@4=%WZ ˵ȼ޶L<θj#G+Ѱ3a^|fT=ޭw=GZ][WSnJѩd"#CWkoucNg8,FҴй ૹ?f(>_1즑k37q%j%X;diBbDަݸ﷐j(hI0@+WP[L2oSI,gK[۬>} .=ֶgуf:U]&9Ê|ylհUڙ=$jf_rpQ5&>m %G4l>Tq*Ѻ~#*+)?@d6:y@9/Pn+V[wM\w59fRJJw>UX:46Č-X# ڦ\4*zfl,L\ǯ答zf:~=W?I+*퇞Y -?2wQd} j$\.Lpݞfָݢp:!'J¯']6."fT,¸ߌ tҖ"nNחK.@pU .t?g+~qJ7MNohy ~dX,NdXorx^8)Pes!"AF$fIT ?G,m7|~#`?[K1rjNU *:w5yX%0.VӁ3BkժF}̓4kVteƹg9\mpd+9/Ѻ :`/o0ei}kez}e-UKy52F`p!bM.u TWj9x5XIWZcVn 4tp~(/i_˷lo^roX*҄<~vͪ,7:@' "@`!F| =kGU&»dC}ЁtNmLPt[+Xv83s2! Mca,EI+S ,_Ir۲pTy(G=m/nN,jK뫧E8 ,GIDRyމ@yq~/5RݖstgvGݣ/l lf ]6N]~n\w$Y7N:E^zH)Wo+ln?4&Q % RW!_cHLZ'q.zB D!`jX@>\i2H)tm3k.;AVI1][2Iׇauy0PɢIu_׆r"S?);1O]=-R*_ȅS!Hf`(E$xJm h0p@ WKlZ?[ݬ= e jJ> RĄp+uеIAyOO E*nE|z }"-R< _(Fl@|Go Dy\(?#/ztwvS TڤS+-B(1؞a%F9hl%|RE"VGWhm=85&R: ޲Mc9rݼZؙ=U7_ZԺr7y# Ivs.n^kX!wkUEjzwOVN|uL ŃX\L(^T"Z\|!R.y6t\q5N4# BbٻDŽt?VL|~8.>;9f̌pamfk3k<VxV0Hc"[(X a-8O*S.kG'zɵM!RdA:EB7h٦+ mAm?2/ّ͌b>ow,="i,F.wfn0]<3d)Δ[8V_p!K{YeB򿦷gP6B|6YOm="m[}tcMG$|oԑ8)Me͝dBqAΧ{k..4ǿ[* zϖ8&\ nZܭ`L ^Kbյ$lM>T#9 \G#[xZD;J>nSBcS뒚 _++[GoZ +b]:X"Zz,K&A*QGT;ЎIɵQ:5֪"ͭ/CBeT$W<3 #& u*QTcbhhL2oZ]JȨldzW;eLE-p_])`G6U!Ƿn-Uߙ,]U'X#|6sgVUEqK7Ie]d~J$goA\žau |^`Ap_z/bOD/he(tHzff^Zvq7 [&mB'iT`J\EF]w~Ȥ"_7(]QcgN$O|w8Iv>plb B Y8 g}ZW}8Ab X06C1P YgP/`R.ؒ+dn\nU :S"%HtSbޗ&ı+ \e*.c NZTvh3y>gOn_qnf5&hsnsWvxInod-):^ܪcB Ԃ>QfᮋLeM28qY d2Nr&Z{w֢kP6xSE e( 0 kumJ7 5;8".})_Eģq ۋQ }8,`"Z$58"nl 8$[Q"/`AL}cGې/6KVN G` /bg2aMn.d8GbO,$ۖS!ŹBJ.˰p%6. &/ֺW `(i#85NZ ?x/ vSTI9dܔ=(ʩ~qbgLV9j'1vө/G(|}xw%1wvKPbեAG ^DZhU~9!rmC0R=3M0 V lE9C=`l=yV Y+&%Vv=W~xȝ{,'pK <h,Q_%Ӌ0=7zrK1Í1%} >vO+ .0!DK{/jM*J|{{pQ Xyc$8!HC:.ڴ?rOۥE?p0W.̀iG>b! ,4d2:wx?ycS^4PWM8I7i4}IUEA,!χiuI4v[RkZ) ގ.xD%c$ʠ\9Ɩz KbFӶ| +PQOsCD۠SHhgNM $iq0Q!ʖwէTV?)aj?+,׾|{k#DU_0W%|e{rby3Ҋ{Ӕ+蒠23EԠ⿓o}aEC\\$  ^KDFHҘ3GQ\N@APȇ)rop&a 9Z!H3S]0DG=cNA7P1z@0g$]$h7GSjޑ+oYBP|?`[t$ 29qF1"FS"xQ9ρ@R=mDIu"B!:z:#\>gȨR%,S:zJ֏>_PN {E3wS:yNk}g" :wg RY('֔ YqԶNCs+#ޡy4u*j.=[ݭdkqM[^`:mWCΘSC˼3A3" GW8 wo k(< ƫ^b{3MeQ)q ;gyvS*> տ)3Ӌ lƍV !rkTȒUfMٱYݟv/aHVX4=$074*d e73&d/uޔ֟BƷ"/ x=БGH]* V{K NT* H"$_u)-9E(ETu:p&u&–Cw=Qma2B`:Md/H\^# i[;w8-o't};GMWt$\\OL}҈4O: kǯ+6`? bPq@hHdB@ a J{I>`^P*(f\16P5$.CUCd9Y>zs=r#WPHSǤR==ZG cngvê rY]l6N@[Be]}hQv7JQe9@7CIhw.K6L}|J~ C3nyB}"}qR#}c7`QeAtn#!lV\Rt עg.U۲ ŤrQ[cءˆ=GU`#̼d 902o16#! [\]eqKlMh5-y +[j|d!u9йc(I;zUO9sX\7dPf낽'F|6ޮpTULaA-699_7gQޤxb$o=* §Y +8)x3++?L:;"z1SO,ܹ(v{yE*yם{ qw-D[lj+h\OXJ8e3Qh'Hs 䙗=D<|{;ALY ˒=8ng & aqh- _z\ ,[|ё@2()7 ,"SA A'sy(S3KcALyBqr,zaЬkO;EKt`~: 3_8}]Dk>~5!j>@V/'&_P'Ų >7]kNb:Q |7`p;/BOSkRμc$XpI?2 Sv)GUz;!6)A7ksusk\ -wo|?$z9fcM fc{{| e<@c\xst,nz& CKvwx\gc|:)1eJ)؁>D*bva(Ms6lpR^*!/O:v9.Q>LQ9 .4ؖ-1~:lv 7_ĦmcBsM.M+*"np,/rJ[5hiʵK.$6M(컬N'T>Vn\&OEϴ/ϯ7es4,&]0 w>8;"mf&f9i [wP;M͞?7bQזJ^DEJIҶD)R$a Ajho5A *M3X.5+R9Y%UͻK%!~7ri(3r4~uJ[fZ 42O=z r "4:qζKra:~rOF +=fzs6&/W폩V]eVweqVJ2`N¹Pxlç w!Ա ' +~AmhC>ұ8@JW,O){b_lWĝb2H/4a® 1u[ #1A)-xNיLƚ~Kr췰@1uK'ȘY))*vT7Ӂm귲 JjAfXU/ G+4?.NCqprpܜoVYy-}k;foMhh`3U]FXI|3B<9.)#*7KkΔæHCQ;T Rguu4KCe;s{hN/֣)h0gԇq5sIiA=(iCG *˒iRm퓻Qf9U)HwBg:| 3%]XN)E*8CG|#Ӓ8ӝaf>bp+Y,+\>ʚ 2ϊK},TΠZlcVn ҐAiN݈3wum賂_$Vi0B=t˦ 05(W^!bi3=\ҹ^DQbf]٩?|I~d%!E \%+p!B%4rRpбA )t~),tؐ@|9CD+\Q){|.Ab;Z{,,X"2 Cܴ"7偯I&+07sNPMbM-D!jMF݇tbu5LV;rH&Ge]Ԭ_ҷoDg;0j@.t. cȶ 3rTkF~+⇷Y e 4ew'i^e"w.hoa(i٣ЌѣuXtѫ骲60Z({"cж/c֨YV)2 n*zhڜ{u@g4ph1}L\,lq*!/l?2x9&/Z.+//ai=!j^8_j9u RQ1l`Lo`z*(+o־7ۗeMjY+?tay\bYb,2;3&Xcqr`eV޺Zd؆20ʹ]~4}MYw{"\2h5Tۑ!lW( 3]k8 }&`M߮e}\cB!ȘJKݽkL`ʙ㶢˄ϦC_>$1HVadez͑7WW4?f I爵@s.Ed xGV 6Tn ^^۶…@vi)$#VYi$|uix t߳<-QGx3՘A+upg5~U W[ImQv"EQFsyể:p;aTkiǖFA>:X>>ȯmsf~NI>:7.v'k"\ 4Cp%znh4r/;HgabPX773yzGor˜.]G]ZrptPRJሷP/ZV5N]~%cV?kaUC6oa,W:9ξ (*KpqymQEkK!4_ok%sXaDl*hE?hN`\z&[ÉȎsDZ#BNUg͓͛BJ:9$Mj٠Bz' C= qT7_옲ckef!fcytK釽X) ?%֞;&kbÅ߱)+5&;Ptz `& LtPjc 2d"+:߸ }?cdU$ |a=6?/$K)D$|Q^ ۍEPYHgse7VzS/F؄QgOdt0:.]1X-˒3\430j DB18|Do~ ;[@KLaľ|^vᗉk(2M~!\KRc,O><;7~ a0?RGsK&P¬€%#lma`V| n\L7@NP04;X%^j⽺78;$ |Sp2pC+` \NP!nKqO`pܿ&s5T.NW/cڈC9,d+eX+]T[EQQ=p*ym^Z}`i8|w^_Ba9bb_m/im8|~WklAK?Ge/ ??ڟi8 g{5! MLoeĢI(ytpX/E``Dggx3~ĊX0g3==+^ࣸMCs:$pПlXN(W% b~++G3yur )}XVǻM^SQQtG -l"o ˀà(?xؙ=ӁWf=KFiޗK@RL-cvYË*GswjB\5SW m>)ږk;zlvK)@M *#ofL H--;rObS׍2́o,3=\ŋ` aQ_9X C)nv 0꜠5-+ ]izOOܰI۰kTГ\|DGUFhU_B5r iޱW,@=f-CHK_g}0vԋ AwPom 4OxYYE[8L4V#`15?uQ÷9v<c2^X^ۦz)g0+Z(y>J~wk;țëx/9@u =C~8-p@N(Ϥpa7I=W{1c@mm|uB/-yI@t|%do%&~0<ڭ^ ĚƑ1f7(-B<j߃zVkV7CU L63@ {,m@n8/g?9'W^+݀,¯zK3'y]?Ӌ Hh2]xLH%X2J5džutݟGKb8j̯H8=Itv& TNdM^Y͋,u.*ʂpB'ւڍr]:5ԭG0A )T29Yg0ݣ -A!% Y;;7`= j7"kiqF4P^j^ncG5ru!eܾTg΢M_ieGs 3k|sgҧ|ՠ <Ѱ[;qEv s>+曱=O{YRJ~Gz -_HM1|kT9*)k~<'LlŪLZh\rPFFGʨ6ElwC?'׶yQRvs+eJϴ bnd/wWǧ ҫ+Ŗ׷Zygm׭{>8"yCNJQ)AT;,?B5mݹϦB^22.E\ٮ촕|hdL܁n΄4=f-E5m_އ(FK1kJ3nY"?4jAv_[@SNnKerL6M|UؽdqW!kI2o,5 U^f#5DdZ&b&Sl8Z"Vm =]MlKDTTSUF* LQ. P~`!$˗5q_TsKڠw%1Z*{=p9AMnkwNŐ5 m  ଇmmǏy %8;"pV3"9iN Q)طv݂Q=zS=ĩom/Oc-U8Jht8dtЫp9D(yG]ED̳_ L9%h_rۜ zt;&V2$AU s'v_ [ U':L6;k6{hl ١7N0`{<{,mMi3cA |T1dK( YsnKe9.j҃gg6mxn;zcju4 YHy1.s. žZXyU6 RݧVRSsC^6&ބ8b>j3[3`"m_^,wӦŚOմqXR] f{ط =חt lytk~^#r1>tS[=m-BVLG0_ޛ~.B5u Exg5@h7j' E64]-74z[ 3X Gluݎ!l1k{Y{\%Hxb]0QC\1K48̈|vpoAg?0(+,{0' -U,t8gJcX4*O-}MϬ ڝYm>X ńO7=?9pt55jUb1wZ3i_7ѣIps5tvnۭ0D}vu,};{ *hg~4Q6n<a8,hl_7?<<0PXfI%w M*8a} :`m.,&?u& (b3Z'H'[IT{ ,"PX+4aeO|QL_0t2'sku.-/ 8"#V, dqE--vc>K@?ƒbQ,mJa %0 \bEDE2,*a[ŖבyFWIm=-Esk GF3OxȊA ^wî.΂sږK>`Fy}̏)-RZ)WTAEL,/Rfl*#"UC!!~d?4?'9~.}~.t&y[njFHm7Ht"eB~HmXH-ϕ)RnHQKr@fA9WޱPFQѶ,+!uL!RlH73$R1u"*)ZQO°"J"`"_"4PWT^{j|Rp(WB씗VjeTa/Z[m¥ %uBJiXsTyZ+ԩuW RuK. KF 3T-jdVzcΗx_jk ق?LM 5OskIB>lߍR&0})R—22
[gWay:L:t:]GUOgyQ,QQ7{ W{rnORs5E;ZVua!1bv%Bҏ`!:a>Op5J‘1@wY~7ǽw|x zR_濬 yX DG.ٖv*uq,E!2&,|s>gu,+|2{,/w^HuT6^l<]ͭ#i/`mˊܟXJ_(oegP, y}Qp Ȇ $M= ջ#[niDyAu[ld_:`U>~ڤӘi@3&=6AW6nZ-]J"vTeNd7ˤ0{drY }fH*RU؉ZxpLzZnLr]?on5yV=Bu8Ѿ^![锶c[MeL+Fi2Rβ8~fGnzF̬+~Jƥe!om=,ݟ\u9>hnU_\`j]^`}d3%9ShYXbFP5l膞6V'tl2㤿޴V[M{+nZ[Fi~3!| ~6`|ٷaFޱfea2N Iv}{7}|zRދ`8GӔ c f  A3"zτ &ս_'CTx-57/|.qn$F?d# TϫH I׼ԫ]ô{uE̬ΤJGox .7%x9 gxBXxU#!t'PڂzQmqL_&;Ueto:B J p1-;tT >U>gҊm $ bZ"s?+ȉWrkQ~F>zR}4;Iy [9VX}*$Y GEspT1?^hmg,x;l;I#m+Olv^ f۩~nNknv , )f0VݙOƓϑ9-}u/.UNI q3ɹL -n*238DC[( k7#E-7/^l:V[trmȱ{o-^ֆrD[E8Al9Ẓ>S7l_IC9E‚GZ ~}2eY.2n7DNHrqyX+ۓ} ۍ}lg[H_TLٛ3P kE3~]v8˛,7NB\j.]/[:޽Hv5op\7p*)kb[uD%Q2De$# VNY>9U.[cn~W2(;HN8i5Rq0 0fj$YFI6c"zd>("5?Z'Bs3cI$H8RrCq1_&vs.^\W<>Lۡ#;J`G#E9M5OKqq_e׾EۢAC>H|M6*C>]fzlll-vP+IPr/%«(Q\ u4ah@ƐY kňnẎOarM4O*OHnWizqڬ肔TzzUm$՗j=.1O'yA+SecxG0kF9S1B} p6Ϣy)oĴ>'hUby1,fCޘ&2@ kuS˂Nv,_I2njqyT[K{!@{aP 8f}]-k~qJm5A|k񧭅2ϛhvZe+'WXJ3£bOB~e^V+RZi}x?FW*8Wt*V1@(}tn֥Fyeq5sKp\qL}k 'eQ^XOO͒O:>kTѷaħ֗D[y5ݰ\T-; 7^<+`&cGx24C#V@.{ g[k-iR64B O~z -`vEG#8vű1V2A=W$UKMph6+R~ͤ zcVǍ !Hitc% $=/E%#8o$ \!'AVsw;4CNn:̤vevK'Ɣ^}c_4i>УnsENO%C`5wktOhR=pE~8Bq `${aK53DO/nv EV cV$Tz+< 9C s-; ?d -gaEH׾D*{PQ8.Gq G"sb&/qٞCLxmݩɟ+¨M\z8wC-эubz 0DW[ɉz?PWjLa (SM:p+c5 1@0;Y q!oXn)tFnmZJoPZs &F(*o-=ûIQ&$0"*UqJjt,Kאѥ7ۣ-=k?8e3Hlbń"^l|bT/OOu^QZvNBC/bbL; f'!~5A2? U5%04B8d1[XdCcCaxJ zll䷇b p.*zVԐRv;91dwǣ~N? ߜg{Ox QgUHp5O]@%@jVgX&#/IV /u싰V>6-hvJB6p,[Xۡ@S.er>!WLԏIGCU~Ǎ͝c!'D}ǣFxFЪeW'3d,w L!z- qRٷR&x{hM]]bp`,Ѽ9 !#BX5hFMcRn 7(66(ReJ: !XGy4^!먞 13izA!ǁ`#QP|@cIћ}VxƮ  Tr4Ի0=Ja-Ѓ N꓌{'RZ8C8*bsm > s[oPl}3:S"fڜ6|3kD̈́>6+m@(~L}P`nn"`B Rtxx4yNq)a 8aCᾶ@ާWB @3"‰jS *Btj3i|Osh<VG1&],!5㾎?NAv.UG4x_d7_@@KRBI·XzJ9$ i^c='mNI~N ɗT@_WECD<"Nq@ߩ-4oLr8\/fL%=vTL1 +Q0U%/)VX¶"B[:x0w#49$ ̀&Es՜P.N;͌C%إ:=n]yl`=1??Z!!((lE1Q찎 W*{ΆJ~Xgl7%t%OiLÙLTؼ35 `8C5Gs9۴$)ٛ1N1օ vAP3\~!gVQZXzt)g`xxeѕ$o*Mö T70@{C1н_r>'Uޑv>O ϰ"5&9|N.&лO-bIAm83ϹY4(G;ɀ u22h\, }9E?JB$0}+Ht>̣{OsO6m5x |m_Z\0 NLw+BB:RgdƁ"7֜{!3Ӣ4v"dHT! D^?Yi[jb_x:҇8&XgUI hfpLÐ&A}^nzI!y.m9l_9WPw 46BBUdgNxwV@HK@uk< &1ŴnccL!Z_=YgyNѹM~at6ghPߙc'm.V|unŒ}+y5<"ޚVԣxi=cGPIj^$db~ƀ2DB='4+|YAe+M{>t۹r\ƣa$2Vq5|_(kYh }5Z<1&F~͗) FR%,JLz5MgZkp=&Ek"\<N*J ;g$Z|`z֝i%iY-:nuWJ3NqȢM8+>dlBz5TF&DLqˏY!g,1$-NQF"8v<q?ǂmm͛$;vagM$ (rz"a|1k'DƉx@ UFIۮ<:qXPxShW1f-%7Ԑ0$~q'_2=E06's[]RiLX h v/1 D7}7v{U/C^fG!#>bdg|+~K)UPd3"3m. Pd>ݕpa?}ŲK.Ȑe!MLm~]# EwӀtU.[2y V~ Y&Ο1CINY8o yl,Ȫ-׍UBks^RJ:6z)V ^vKt9l˭QvhA|eAIoG4:ioa[(V81oW6Ce+z(,`f[+\`p'^bL`;@U, ]p?'KZ}hzI}5é4:ӯCXd t<`sŷLv}5^^,w&6Gs2eVeMpjoy!,p"Im;Dogb.˵b9бq?Repbi&ny(%1.]O(_3HL$@NUŽ=dA:MJVMν>ܛ'~^'%gi@ϻ.-ڤKUo_.Ӑ%]ǞQJzef1V"Bͷ#9hc-e BjC Z1{7b Li 0"3P"yG7bF.\Zs~p'b__z/1 ƖKav4O!r*oV{FKTۋϨMrs=~dxvk5R4ca&ڴ{Hʑ;PlD/dfvD@P>o߾(Fq-<9*B|-׬Gqۜ ޛ} vC>y/  }%C\gbs6ScN#Co^FK/˛H{ CtL;aezJsܭ,r]ߊS#&^5]a?ěNxWƝ!m}wQfsS#H%Cr ryr^NRk-Jf[1~]sєUl*pm/VGqE!qu8:9ܑF/C!D~_>tN4_'rxGs 0ia^7d5fgh6[s Y[аIV :T3}i~qIpm3W^hIX^>51MPշ26jeIbŖM{>7ed1DӺ$.c"Aʞj>C3{iGS6CZKۜh\f`5nxZٳ~q[A8$jtq3 Do_̐nd22F mbEis5eQ;$E-jFwVaQ-jIhɈaG %u@a6 #K%cιj~u Qa@y҃ܥ> W#pI"=a7 r95G(>QC !jUtIWgVn@5" A-  a=*?}H8[lv H^:~],dt;>~pN#яOd]Alh"9邧bw>X3p*L zc^dϡPϼH/yeIޅ\[v?X[8]^q,px?0/99\.*XWTM;E6+:Jf||\ť?KDtcі]O/e ?cݘo9QE4~4,tǷr[8ΒoqDr$քk[-]Nit^oLh0`\[h>ڎ: ,LT^' ;w?(M%ьV,?&/E=I>Lr QKI29-_ICZ*ʓ׃ȘQ.܍VBhT{*Է3e#4)/%v'O'3"U΢Q+GKsO5N yX%'1w\#>t f=1y%#?:u6K}k"(י5hLTXl9})POG;*dnG?5q(Yf]H.iGkhkZ^G:#ci"Ib~:@ڳ&y8 lJ|c(9+N*dȭ+I33 x' _YvĒZ$94X3Ѧ[z] ;5uӮXsytQTCL4 {Ͽ‚$݃+HuiI~:QjP%"Rl^c"Zoض{Xq qdSnY5lFfpc\l3 q_pOe}wRi+^K>^5D5/j/[%\$%|f̋/} :UjjMY)'MMy*Ɩ}=!f]]y7We%Q:0=~-gԨ"?`-Pi){$?l Q7e+ q["D@8t=aZ:E5%kڱK@ =N=G @e ͩ~'jK6WT8|?`u> L P:];dWj|?XrI(N @UdR,:19Byߪ-џN5c^<>d ڄ=%MѥF i%'/"Ow1=nN/pH--42sk7t[ F[JK~MA漘t'yv#S#F⭲痋ճƝAމVڙN l)hŭ_nli'>|/ۖRL3W gVsgn5"bXj,1yœtF< sG뎎:cүcw[%` JMawBQ\ WIvfZ|By'O 4xivlRHqJ+ )X2b@{.ʴǙ/{sTZn$9O3^&?G'gED{לLp9k50-Ø74;YJ4g9ѱ<:y]UrFHv$4.#bI%+Ju2 W)mf'mgo;B#"j?2vB~=JcDn:z0 ysnmRMvs=m2Δ[?W1_#.t(L^ʦCW*ӂKmKja_<t=j'EaYV٫;[3Z$w;YA ȿ 1QVz>ր095YUvǞ.Ef4y\BuyKOӲ)&dXnd'~?жeYW]_t4vo9QFz:y6bb<`` ޏ^b'7KU߄%Νx`1kW 4MG= g]:GH6)݅*hGq<ӐvʖU`L!ѻ̲u҉ݾd g~n * ᛶ.WBߜeoyo5DPIM$G^G2^ƜV__kb}TVN uJP#_.8^=uQS#5Ǽ`|VI]\U-\k4ԭm*dZ^G8L<$Uܣ\"e PYAP?v;. Q,؂=*9zd*[ m#+IwbW F#UXvm3j:f/ޗ)QomE.j7 C><5D;vNg8vPV3ǖ1mo`5 ]LJ/ͿB6Za?[eEBs뇣(-; uy% k纣GcsQBiMqΎPA /ͶO8i_e {# 4D$%zŋ}T=y[z'A@ھ0P5Ek""B~~~hvOM{rȶ;}Ue 9]Q2WxܿѬYAN3 i!/ֹ@.˲>czr"iGIgh~ ϶uosΦ}SSԑ{⠂?<,T_ZLTEa=jh>i=$'7-IiNZ.@=<8IS t?ɏ(:b@Htm0OehP$f もۑ2W~PH%ipAT0Gj=%O[̰:D ۷U`5yh_x9g= T.&tB B80-l66: */rUզGԙH)tȑ#2kf*N`QSȔetn<1wGg-}#T >/(G-(j&/bT3sa"Ja#;^Đj;J탖>b<.Y4\VǰPTAr橢5~P1Ď*^d} R`4'²)cV}w>ce֝M- rf]-!Ψ h1^$^]zI9qRbxXG4fʛJJ6Ē"ƻFN݅#2ǛWPQʄ&cف #ot1a!ڠ*bG{YBcEǔbQ(͟Ta'4vcF(WR% ]VF~؃Ż$ 5__dpUL]ͼiŦYГ}x o'WH#͇O[F~;AȖ^rOӠ(?m8(L4@v/s>Eڂ*}p#ƴ돁^GjX,FКP1 "NC^Moh#   =Y~2͛狢9kzPYzOy~<^Jp﮽2x^e}EM,{ Р(< c/kfQc}Ph (%($ޘ?0^f^ܼ ϋ#̼z̼~uK(mwgVM8Ͼ&N{KwRi83x`U+fGo+[QOدÆ7@ h1Ve|Ε;[p/Lf5DٌۭB`xn*&#kLL.l\nc8"b,QC+~٣\c7:|VI&hoTU@^`RΜ{$KJEod֬(pbx!,@e1Q!@|F]5|:JtjFIXBAf݀99}Px i^^:5 ~hc#M[0<pv-^~ м ]ck2NN%5{w_vsO"klP+o}}J[ւ= W6YlO؍9O4~iM4O_VrΒM3;:J֜QM۳z.Oo˪[R3f;Fj{Wjo3e[9MQTf^vlZVsu&x$M`+?q_3r\ˠ@ l:6a*m qtђ׹H @992]4mI^wp^KvV#m/,J>]Ai9=\2\E!!XxSR 8i\n eL%@J}䆥NNisUn_k!gxA7Xl\B,wtxP`s sC.^z|л+:V=/9lsbMof? v[:@ƫ`ؽ,2Fo@mX]/ӽ>}6l IxY~rA^v{f2"LHW(T ^Qf}F1Dq#r^\,mܾZ3VX#VFK5 st/hc,+$Y5+tw5m|U>7q)ѐR{ u-r?dqtuoHܸG5Ygi lYU{Pu祾@i 5 ,čR;`)4wu;, et_QXhiUy=ncl}TYYBAcY@`:npcO%)('1qkH!չ|Y0, '}[!7(n VyL׆3x" 5ȈS/э迬}k5nz,C0"*5ꄴewn;_&e90ZfŜ%eSk229?o{&t#9 ~/tG]/S-~D?vh Iuƕ}.?*RB^$FҚ^,sOV!eP&lM2 Ga+~H{L>\NK^&*κxtKsa]ᱜ²jIZz>֗2bx5mzD7.5…K/ˮgE\ߚK=*mվ`E _@y_c;n2Mը`e/%L{d.ܑ=V[_'D&~y2 /Gqbtʙ07_qgiX900}B 7p6q<@;j$qS_X#DEqwm|;e.!=Gעu1`\p$@<{s=ۤ3=Ohe2QQ3o^z@C1nl֘Æ5ORzXB[?yJTJy j 'VGۑ!2#QA1mEmZY+,щk.kX,t\,0Y av~O$AڷhEܻj;-^&lYHD/˸r|L22Y;Wj27$rɶ HC|J 3) y+=8?`+2ʺNHPH5p&pe>bs-W3yӋҶs@mGɎ=N_2 %ʥW>b)dXPl*r+cPce TaV"p?$2aٛgQm`J7^H4B~ *$hAݮxC-ITf#9ڷs:$Y&C@a^vXP+@<*D+Y|.|<ׁGǴB2uQA'S ^x-l3eai"mgX˂^ÕqʝW4fylwa 00z~S޸ekՔ ʀqjۋ3,Gx.v5VyW|y}Qo%pD`rӻ` Op|mdݩvP>q~&"&ӷw%!/d,1{߾1\5/KAIxT\'!7;Wg675ĪkP+of׋rezYX=`zR~g{d'?iQE'۴i#JjhD nB$xdmȟxa.mpS:5 Q-MDz,-z=*J!*,0&♿K8 Dg@<^h1a֩)nFtvW$XS3;ԀF*_Jpdީrɗqwlwu<^rڙrnF|0wK^ucZa&_G2$ESz'%_sn5|OebqJ龕KPWǕVGEy3_9q mx52v8o' zZ"֊;ۘӥkbQ8R`?;xf嬁E8 ƽ^쬤ܫ|T//{ï-U*QS$Fz;%zN[&O/CKU #6 1_"r~` n6cēW =] 鋛l{XA*5L,w={u^.wXGEݗN!‚sV[+ 0)OQP 3ϓ8YՆyhOœatyS: C5S7 N|[AqAX[JNF$ߦ1q<^ظzιn&G2 -[Jɜ#|B4Ȑ8Tg)J@y!q̹C4\l\4k)ТXi=Pm)O^^We֜|k+}P S14#\k+Ȑ'ƫЭX@՝?xl$(rϽD;Ґ ')A.wn+ aSYݴìĈRosLة[Œ äYg@jW3ø3VNXTkA매Cs/q 7~3JC=hX}dv?8^ρNXvI4uS-6do4WV\5*XLdc1n RΑ83=&EO_kN% d #4Cu4%29-szZU[tg۾-vE ARAADJF/km/1 U.pSŷv;rG{~?(s6]I=5^HAZa7WuhҦ?;8nc#9Czܶd",CcٗSZeqFEn>$1b#׺OWiwlhJ$2$,sb:8T|b3sKYގuݓ%;&$PȞ_/Qq7`|S>4y?ѧI!-IzfA|y3ڽ* C ` {il8FrOLťp+zI`V-xc, KPd " xT\Rա2@ݍ;qbM%^z3`VL+ :/) AD{?0ZS=Sk:YT>7YY#6augNȧ~.\AYU@PQLdʡ`HaSBh(uj5uT}DYOb[@%kE u_C@"@#F A{|+x41wX/,cfA ҟhddf|J˱6,Ԩ ^&k53ݦgPc4;p? !tzNx`gӔ7`%_.$}[4< FF2bH-'CuM zUjpj6-_ |FN- cjDA߯#T H :(.ڷМ4z&DISuTHJא@)u*Q(h2:P t<ViP0dH#t#AѼ,sIzNɬ3{I+u`6I!.N[CDij gT__\$ݯ"FI0O%ܴn⌅#=d)=6RFgҀ c=|&ClGUDD % a$Ɖb_fp9 $>|7e[eg'L|L7Yh>u24ĔX0,"pH@}*RxٿARmIa D?&6Yիf!]!R!=bSgj]gL=kqYřFhF Ķ\ z?9 a)i1{Z< iHN⻇S< ym Ғ7:(*VX=lj-R־>(Y˚&e'Y?MV&68s)ǔ))KkQ Qho%ҵQllnnkR1j-=1~3*JAۉ:x,U.Okl~t 4Gq 04q521]?y}=0xKv2˦M׫^*uKP6Qbr^I᠈Ŷ'4ThxqQ ܧ-JOh[u?/4ėAIf|m !廬W"a`@4+<}ln5x@߅dާdQk=[~ܢT/*aD ojt|e-rd+yatn=1#wEY~,spoS= ^c-m]7ҶHuXZw&t >;έ*vkp|bŰ֒Tc v  ?VOn/JYnRQqXnC)SJzyC\L>3r̶te|h vį1lw#!ǵ<ֻ{rE0Du'u kl$93)a8W-av;<:ydo6B L]UXn(!Ⱉ\GGV)nC;9Jrc?nmMptg(V:=Pѕ/C,BiG^-`Vft:{mT#X lm{lyP0T遰!Z0(U2ʜ% c 1R"8YSQ#kf)(^d1c a 3MS5ZG G=B| 5TG2aٿ籅 b\ZVe,7o;rn#VYSBC7'A/WM|ΡẏiڛiqFe.C0h|W av@ՆH#\C0m;,3wW1SkaFy8"FBfx.}Q[H؄$t_yP2w@̞++0S Qq\H?]nng∵_Ѣpx7NZSZE'!DrS  B&rח+"64P4[%sa$aCј cn ZB'۾EEC3aha$lrD\%;Ar 1SB۵yd36ؒh!.arc܏:2,F/bY!@u1aR2A^ i`1~u{1K[XeQ L;)|R81 E4A¾'_@SFxD@/wsǭvR++>ƣJnP34s)rkN8iqjl!r"3,{Q G1k^B.r3"Rj&ULekq9 a% , K(Tص:‡芰rd\jX%P4^XuŢykv2+^XG& ] @p5@#X2z"Z-,s2X(oL@$ oVYWYz$in{ p\uLb<-5-4ML6 -`$Ae9V05on 8pyR!+LG!#w2UC4A8@ ٸR"V-)5mh؄hر7wĝ*O'|)仑 b9d{,vUSWᏨ\ȇ0?ME|-Tå5"' .bl,ͭ˄KRMFBk[_5#2u ێ1=3 S5z"'c$ߒHt|͞?i5>|.[)WlG0m1Fʲᦸl*ra>aʹw,A w]3_8Bo1lAk-[k۳Q\2$GH$N1@&i3S'/~XpAsNH`c^S"pwx- rn 59;Mbj9M Χ' j@s@tFb"mDBYnvM;!Ũ`Xũ0fɉLr9D,O;JLKXN` BKJih6Vy_1!<Q~N %O(ONә5ӘUX5S`@<2 /%m }2x&돿}Ov¨Lj4?a5"0x1!+7ufPbF~V'-;t **j9RM=W0qTZ=_!I ]LMM`X$.kR8 n\xnnbZ%LMD~(x8S1!c)zJqj'l-8zzBCRqb]WZL=E?5©_ԩ!y9e[O!-OA+ƞqvƣ >}-6POي@[ R_cxǣ"ߊO U |}]%>$!vdRsp'$,QI ?d~bܛO/o9-CfdƖk:&Ԡp/ ]6~u݂JH@JC;p 57D?WK,͂d5_v+Na?Jnå|֛G1p gdL:L.zExR]ޜM+Q_93rroW@S=]GbC9,GqE7l2G++-2O*M:a %IEE8˺<0II-\[%#e>H즏:=W_͒=랢 G,< KsU%OLRj <5y7F6 ORV(;ihxQijmbg9ڜD:|Yͤ"ѡ.ľMG1)8F9v'"r eb܅$'-OP];+ CI!>+Us}egc2 ~oN3C,}:w&8o\9 <' T[>ػ8!CY{O͞7MV*{W}EE#к: .} :OͩtzIPcKcsz2:ӆ˭Qo|&Dl_uY):k8c\J[*3.6sBe:EmclqUBLrmJqȈ\<%y7@j]eH +ʓU\: 'SXv$jW%Ar= 1<>R?h’y; ^r^ 0qsgYwt8M9hRmLFURL!(C<0 m#yθ:Cv/A휘.(wf44R0#'^8aa 5ɕ/K;ʡ̱bdrp\ B~9n7ӳR/O)*J?wCc".')X?Ip68AĒ]h4Jp].Ÿ^RTύ\ozL8aW~3Jnݨ*p4'WL;&G1Yuvj~'T)+%$vӡj!wE4ԭQ9< N"h8mP> *R#6T-ryhUPc*kfL`7GE_?%[J?FUnMն{,*:&++78:bi ]F͇jmK⫩DR-ypg-?avjBHi#!LBSH~[$Dhrv.qr/TmWo,w~)m9E2Kb-Ӗ)=^JR=F9&-brIL!):gW bgF?DBD\_GYSW›H0E ĞJMq =|{9i6 K,`K,uKZ7~D #Mm].ẛZEM=Dj 10DbpG7QWڗrV)[ia g;]253X`s|i#`< '|[w;sBR=.",Ct94=0l^*XRC <2͈_U)R;}Rkn4fZs.0kkGjU|-;Lלx S̜yD og8WU-1>[p,xȿu?\j`YFW ՟Իu-_Nfxk>}˟_d.wNc;9ۈ&֙Orz95]g,X/H@Iv'e\dI+1VᏇh~$$XϲI\Z'\ԛ|A-Sa{?CzOs>^oOt"ŵ4>_\5X}M]EI,\ $GOwf%^ծ!I\X.UKfɴ^HߦY (Gf }et\chzM#]8׾[90Pk>&?:?a ;[9Љ/\>j6SFKd X.o|eJUM;&sUh0FKcc5aK2B(0cWp7q6DT`$qt.]rQSQ鷆*s2bE 10ʖd$5v:Wĺu%4cYY偔2 FhQbm϶x`3~8Sޑuic?8DcIUm|?DbH/xtUFR#=gy !r4g@ߚ pPvj H+@Tˆ ~q4n2A%@< MWŝqxƍp)9QFQWˁm2֠Q(ezqJ˺ |+ဋ䂒vmeǼu.ěRfsU8O_6QU:.G\t0;&o x,P8<0Nr67}ǏbnwOx1N^M8eNb!sR[#HlfJGy/YVI/2 %5C \M{^U_t Ubըl2OWdᓵ%VhLiAڟLS֚kA\wdNtLI-MM$WYWB_ĄܓXߕ~kg-%5yV\Ak+Ix僥?nϑqfxS!h=r;-w}9(:|KS(w_B(c3ݥ`_*? {+)' 07ݛh_,z{W|_Qz\g ׉'4BNߧX=A ϔA~zvn"G<nG"{j+=^~}*4|WϩgpGF)``.JY˓E)GFɜ 98&,Y/$-z<ՠ(mx@.kH1pjrup7۝:.PU(rHS`;*L4AH_ƨFβsKD+ME k9Nt (vSB,i8Vyܗӫ@)!u ⳧hy~08L90Ƕ#6D3o zsgn)$#=T[_ H+h:G=+<9}hUyILNqzt{>,Ó̓i!V\0ߩ8l':q ޡWdq'VguҒ "=/!lx=F:n-!Xk3ҳ- MOH˕_ Eont3xY ctKִԗ? o\G2^f&ez 5T hEEqF uo?]BmEJ>=K`59VdD: #ЇYcnb2 9ļ 55H y|72q Ë<:0j;e%2Oڮ;즭Z86A^ֲ7co7oܲIYR_I|Y $kqİA 4\&x lOV řȮYEZv :X,&?K)L+i(*UY ;F,˖MQiW5|Xg>݊^ J n$&dzQ]%DH]i^QjzCS%c%ɗ(xL1"pdFO^{I< d HpB`t!6 pcЍmɚո`<u@!ݽqNhkEBV W #Ig&bi~쌳JYaǠPP B{XcGńIbĸ!g Q ͱO|,9v)4r'T maTrq%"OaP23"t޳ވd]FћmL:ghOH uacB2 uv0N1 46̧ F (Z  'qTAq?, .ÈY g4,eI:ft K#ͤ3$"{ [Sq>t Эlz[Gk}T *U1wRb$ثj ^8/q1bc%c\BKvwq%tη2o˯7Fna]#r[aGLTʳȎ& F e-ލJQx?wMZ\")Y|{沩+4uHd/q;|q߯G6{mDhPNM}4Y ziczV;f3>g,/{}jC ^ED;(q4.p ❩c&jmX(ĜD(;k|8pb/*ƿ.>?!6o+R2(%߰[pJ^9/Ҹ˰7my(HbA;_T]-|yQ*7sčh .$V%pn[!ХrUhw6چ_,tM޲tv7fmͅL؎s>?Y=OT`!h194lx`,nj~oPpj+ Gqn@L#׈n &*P' d}W=y+aNU?ho''W}9niH8^dN!Z, yu |6R?t (qjWNJLҋ VJU^ui l_S\hF2Six`U~>H+j޽ۃB$3fZ}BkKE:+;6VLSGr+\VyԪT-F.)yc`Fo;г蘉#qe H:=/yN/"TT޻TOZ BjjwM&m?x's4F(uz-BDT *b ?nX0\(m|ě6}Œ7FoHuT̞,K|(#j(U @T{jxjFTY/taC3{8z:YzbkR,{ÍY"HJ)Glg2Ruz9Ŭ"-R)',Vf{B|~d (鲛~`} =)9;꾽 jBY}nL #Hz. M+eyV=3̦H_ERBMޔ"%iZA:v5eCΘ^`**%jfL)R:Q& hl`tb*UOP/AMiGщ9-~K/䩥~giDI8mp[d)~lOZ_`.H΋6 W'TҏCX_ȓe]!&c:=1<:mGn֛κX o}#˟F~,,iױtŸze؍.X~GcAWj.Y.ෘݛkl-eloDA4AqL4)#k%T~EO.4C=u>8q,KRƯqR@ΐ[%6`+cG0*)ӀC`y5mi&OxJ/;y e誯!,Rn>WkF URO;.`]en!?טxNׁW $iPNpqHK8IdC3I³n]t1|zM5ÐYOtʶ-i!B@ KZ>!O&isؑ ǧwWQ-= mjv$ fn ;D,L80uCcWk Rmpٶb\ {paz`"G?;URAH.5Ïf& ^4/dJcϗL'/Le.|P +Aehe$[ܮ b}`k[/^uX^abbˈRc)&lk'(Tt|Ưbː\ U8# e}dޢtvE{ pD|YIU'GY Sނj:VӤUR[ U H =j0MLpHc~k|"7E/ti(=Ϭ64R`-'&|iXsT>&r 4* $S]_98BDi2ߏbK]ʢ?gnS U)Z~?G𿄚W8?Hx7(ׅN ~[bP:_a+^\ p[xLuXBq]&ZzA^Cͧ|"kNBЄH*/Gr?Y97uh!Kt)2Sho+$i8%O i叆-GMfy3:6c^Bbvb029D̀7[>fvlF§5A mV02BI-_WE>(L/YQ4/2pȼ8ﰐf!]TJoc}b7M&(RY K$i Ә)\5)C*ߖ'\1dO1XtJ􊜺˼ +^ŠdsU'E|mvwz\-U޽"TFt#xA\/@/@L Z"7(\ wV埈Ucn´Tbo=_sռ 58s-y ;Fݘ00WZ$Պz)E CUM:lXlt6goirҦ/!e~n~!sc`XF #w.0kVt̥0U~5X;`װ>O+Ì'ZMٍ(+-7f #6b<%:M qRc'];0zA.ޮk&K-T +S  n?K_$:*q1$3 -˩Y"ic߷2 g:uV3xQ|~ RPAYM_YiaPj"߷V`!R`1πP6JO/2Hd X8Q@1҈C2LB&MUҡY*@G*I. I*!OX.=zFkֶ; H/p ͅAۀПG?,@&+88_m_|GP< =]?ery"Ev4Пf@g +wg6iW˅dܯ)6v-0"^x'RV u($w?5eFIQߞLl)ۅl:4 [?& 1 #׶U>9,NLuIS(b Է_sʙg T>׼ N}=%5ʮ ܴIkȬ_MJm0F5-=]lYq;!Q6_Uq?ix?}*cirQOq{}hKwiPYX.UCZ9ukh*yV" -_wsGT.)%R7HtvsF{8*("[SFf8mS1f6J%kc\ະ` :.v4c4ī?:)Z" *)>g+`j`qpY'D-Ow'\8G:O*W@(W,2m@$=)Nvӆ7@[kttv'1")ܡ'zg+쀧:2u,_ZNׁ0(66*fdE0z@'tHg&j\6wdRӣK W.Dc`CB=I+6(\ʻP_@Kvd4YtDNI_xu?bttmm ˀ=/R:MЂU eyR3@@Y5t^ժU6uVig?"<@f?:I/5\4Dcuٵ츗;Sb! xOs$+(6_=qtsW>$ty\E1X;E5Fb ">vP%wW6pTٻg\W.y?&ā vß?˞ӳ;@LrRIc&v==!ij"}TepІ h/yL(z+/3--0H8tilwܿo d]R8ߢH % D+Ӧ NVgfRG>9bwFn#r104s8:ҍ(7! $飐•> O.IehnZ+Q7Yt$, Md- @g(NjGdSGŚ܁mTӞP"+pܳ҄E: ":xG]%M OJ+8U L"#:DWj%:^rUÇP5XMj`; F%R=t"S{ P*G]%"E9*<|Q -ejEo'||8)~SB1hw4׽zrCK paBNcJ\T><ֶ YtkRԊ.~cF11)26+v#X?^T>dM*K#]*YAGQLhjo4ϥbB/T|[T7^we Y #{Mtk5,Z6@pD*e^Nq_> ]s\GUsѪ9)HԪKt[L^OYǭݠ6W PTebf%9G1R}ǾukǢ ܛW)L tl›ix͓ңw銖`cæ*7 5rEߓ@[YRU,joCe#.aPۖNV;w5W}]q}'SfDܪG-4qʷ vm`+]5P7ldWjݴѴ_ifqlZ`!#9ȭRk'9v}@JNTLS\0/zޱ V}"3# 87[L!* #~4Oċ/z`sKeܺN2̪5􄒸UEzJzxxK"" zfv"ϗWN.lsTaɋg4r+{-׿rQG;9%#!ͫ#HT {'[DzyyV:cxTRڝ \ªXZddbg-AvXZ\rҶ;ָ0zj# G=ԟ^>cNux{P"V}2^O@IO1E\EYF`O+[\=w T% AbRb*:ㅁJT 1 A: o(D{ju0\_^qP:˽G+!t? 3;l5m*;ѯ vm"tD5${5ƿ|hroC]r x@y\Аleu%42i4k <@\+!V'.V6KxY$7Ǒĭօ9gYY }0'->i)gc@=r^c,x?]?tѣdu`E0ewΣM_,R!d H%$;]o[v~,Ìx]byK~!W+EW: ;=Gf;>$⍦Z"|ﵦ \DF7>^t#(۳AԋnX W>'z=(s^qWxM`/4H'Zq.(Ͻ,9D3맑-*v㙁x)J PŖ;\53aw~,-Z.3${AZ" oQ_/,ww]d:دMσ`Osh"W7dU)谋PXj7Dž܂hPG˭}a[S+-C]kz5ƙmң]NI@坯mNE0=+ب|E.:߾qP}OqiW]G.}멣4T-H+RMjwdHQ*jʎg@l3=#dVpBP*H*;z_/ ڒuú:o G9sQzs\قCIн[*,cbEʟ~lmy. %=+T6?[lY`oTHw;nRG@,Y0@F~Φ~`tq\T|R =ߥB7Q²p"ca;m7J"c갱a-^ǎ-(E#G %²eZ $TfRjf腕AC{6@ uG7v1Vs;3N<EDC:(DO T*ZR@a!8BWJ=@nZjy[^U.,nJJ֏SZSM\f0^dXL"\̴' eFe2iΣnm6i;i#_n4i {-qz%l eE4fUh iA ]NNjQy 2 YOO0IB ơ((W! R  &F=0 ;a(#0F!af3H= )ٯ(@F1 A=Ϲ"7Tz (蹛N+Q(zS[vAڡ~= K& #?(rULGտvaH'te]n`m9ۡگ1i6(eT*Ua D0TGz(z.V I;jEH8DJ`vLiӕ;@A[FdsPv" /UL%)EL$a^z` 9ouT97E.7ӽCۡ!|*>q2=4)N>9Y,h=QFfDxb0fee$5 Qr^jߝsy0^|eŋU7P*קV+nL>>]9)l07}@݌$JHGí(\FEy}q(wKkše`YBQzEҒfkO3vnMmϿ_1gǭsU}^$tHuk|B71_QP7ߢ\gY-@RHv,ʆ׃Y;4mIU 0 kZ RR$~*9JRz!vKQ{o_ csl*w|5p~ nemz}hU 5(w,A& q0|V/ T͗T%O^w)m./Vms/_Ed]}Q+XQ-f@r 7'K)zQLc\U8r+g.ǵI}DaԤ7<8 \q͚c_EaZjE&/qCY<2N*B\a{/YoB=jO2k2ώ}gL;d0=MB-YʎVt2m,qL[KfK?v>ڬְKxdECY!mHYK&6O "Q@ar^zJ `kd+aVtTf%YߦŔ!gFy Ț7ct}I!p,u Oc]J<ص/ $nZo5 Y2)SYiهkV &(Jx%QYm`i8CzK!y)a]#(E(rGfB\7pW $N@,tг䚑Xk ֍K!{ߜTQS?^'LF:DТ_wk~ϙ+ Fl|ZUNM$"%<o|+"U֘"{UH)K,ewTؕ™!>UqCr:/o^{I,]AV5n4ώ޾ȍyϞoMylP]-[s:ҳx!Ac)yYJ';h2z~Ϡ]G4$\EB8k~  F!H2puB4tY"4QhSoT^;Yܵs#!MhIaloAI^0~ce?;1ZY5*1"2r/OHSO]B8 yh:r ^FrbUM G*(VY#%Y 9rV4-zgj|=Dž0KKNW#&BnB\EvKUAu4Q>Xj"d`Xe&if䐂P,0ye HjAΖ#1, nο 'm!( 1d^tI‹\j9Y1èdZMp"Ԯњ}Me!4&;?%]*$cgĒZ :ȅdik _a>KU5>[rm̊Fi}x{iòV/\$7:K`o2C7 ~fZJか-k@aw[rgv$mWRrWܯHncsI~=l,ʄ_Q/2*XSH'ت"Z]@nQ2*KEg ikj䈥c8${x&bޑ~ƚh9khs~FKDb.m}tC%=Õ , 0`T|qd$8B9s7GU-Abrx3`T~4.ah Q A-uwoP+:]7yD w6l Sha.'Eb#N9xɽ|NzűgB%B6{AuDh=Kf=FFzYc2O_SFbPJU:i W-)wX:`]wZ5 Cca*4A> `EQ`"o t؎:ddkӕ ^DQ [Y@&:PMdq,SǴ4{8d[)P6b \6-(Ve$D~zů7<;{m%y#=Syi[' e{ge̾?LԀ`" gͼP>>< %w4'-a;S9GV׀@.!J ?j.Ϲ;^8 5'6KX=[* lڲy .>Qd{݉8Ab|sIutQPptuGY2_6[sZEڈmۯ VnE%*1Ʌ0,* 􂰘;>yXP0]ba=+raJZZ>M/zwPA%xLfMȔ]Rtr9iGm7c ӕ8c+x$s Rx+aWڝoqf\hEr!@ 'aAQCއ mo^ iJ~V@\jFab&ŜřC:]d{̆$GӔy~h0)I=km:]1qIbt)=N0662 S;xgj[7׵fJl`>J 'iar}{kU m#x2> 1>Ntv!>Zݎ5V4}i\IaEAlbS?-'+-כſjLH6/q90lۅCykPٲ`6Nf&7ߺO9H\6H+i(}PAGa?\m0=۟KK>㋺rϲ)EsEw0FDxa5ʇq* ,~Zr ذh OpBw~T3Հȥ,PZ Omcv[gbQF2_lY$g1x\Ɂ~yU?k,9yw\%(Lb# R;Z@쾁Cύry 7qr7F]x\peNυ Y&EOGh`3%n¨FM`a=8vbq.Mi<ǪB*֩u;sYDW?/ܣlTByUV'f4}4/~Q"l~dh,ȑf8tȺEZӞ)ÇE͟ˡo?{İ N>JdRT׷,V{IWb q^un¸9ΕfXcU=k6Y̩#eWUqT#TB̈́F~KiDr׀t|QAx 6ݞzN'ZJ{%v?/pnbf:q!dЪs6v[SMZV 5oTzѩKIOswu{]Gc#./YS#StJx :Qam\*S};NBUV*9M}ZLbVfzo@`IJ'':\mwd)<&Ҿ9vi+# 6K@з"Y5~#)S5?R gu0:{(Y,cdӍ7[emoHσ][ZqzvW;[R'lLEHM^P4yffؕ7O0ai&|)M8lЯD<7`rך?i|3^Yݪn]Y(~ruuv^!Z668 Ze:P @5z(ՏCXYxAHJ0\ U]*&">_oaO_wT "~ _ وSU˱agh:2: w7Vu3P\-.MN7ݽQ]t1vz1x\=LpFM4(cw"Y<~WN _Dw> spN C`pey=G;PG|DJ*#p̓X\Tbkev+o 42,gܭk ]sw敓w 3NZI;xN wl8V:}$CU;1ÙRa9*RE%8ui/6e ."Gu4:ލ8ZXAha1XY$͸Պ\Pje׳*d6q|n%`4X|F,[r v 1Lyz%80OlmNы'ytk$0*JmF]s0>h 6c`i­Z։K^g)˿SlWd._ZN8%~)[#Ű]dojL'lZY#bj[oKQL{_L%=_1OsS|F*V}mN,Dz:LsoRƾ- -gk2 x,_yKXi!~V(E+mZ. J{ЇpP!o@ rE,9ޒO&R֔`A&^L1ǟ` -ι(LKgћn6~6Efy}oKlM;x5 @z@'x"TZrV'̅ceo@\1(pX[Փ*yy%_&Sr X#rV?!_y9ŎcdN `5Zx%nRuaR;شǂ fQA?HJ\@Dk3)+:7Vs+pNe@}}XզviC fK">&UW/мePcNK\惝x<:в{3в^Zs~5￞!1-"οW|a<J _DSR @~Éd0!D[ަSmZU?W*\HdZRV0 TL+*RRDhm$u>^:_7hV( fFTV'uޔZ$+Z4Zƺ"3drQTkAk@k@$dzZ+}F.~UK_]nXx>je6O]j*'bݭbo٪]BG"Ѫء-UJfURY}Z[ˮddUG5*!z"g@Z!ЩJh *R8P*Q:M^VST+;ՉNA6 ɦb2IN/Qp} `RgR*j0H&RTn UGQuTiF5k띎db+*f y:'ir/m)58Ԕ9.>]1 3[z`Tӝ8FͲ]9݅U*CJةMWWo+ϣXCt;hU;Y_8έi D!{}}"zٴTvMAz,-]乲B=1>Pb%HZ?Ҷz_-}JsfV{WrM\/^igEV[Ke/r[_QOܽ7u׏?+}c\af['9d zkjkUT'x>{Ipw"eW I4xѢ%oƍH|XÄg٠_o Ӛ!)gi p۾zӶp} z~͒Ew:Oc>ddms6G%YW.=-wmgCCY^fazyVٯ gHM7|q+=E,5A/2Uvs mUF\d+K= 8h"aO,Xp m|.EO[Y;<  V<UcvJͿ7# +&N])g[$jJ ~`j\~F*O72}/q-^=iwX쌁CW3YmՐ"Nqg3> F(s'%B~D(c27Bk :,xaU/n@"qtRgz r[NiE4u2R4{عuFibTyUv4 N``u7G:44R]C%]21m/ѲaTtu?WNxOg#c9/!\[KHM!jl|vi t"R)N>: Kp4)rfXgm_eq=|y6ᖼb/UۨVKU#~PVɶ0oAC  (eFF??iF4ZN GےvbE7>W{-oACY]'Exfy9o^|w:VBEw*RQ&1H[8Cq8ւja(, izj@||Η_qRAn w3p{~4\ߤ5E4D?E4P10qF[^ν}]7+?cl93wvHڕp5Z$Y˽U=ntY}O?y+Eݷej CG֯3|\G[m[^97Ei Ϧ\u|Y)w $c-g־w˥*6]*r"O#pku݋/FAQSiwەӡuwL:koQY:*2zØ?nO0OƼB dl7acdn)"V&gmyZAuIwU.dҫwD$)^HńyH{O$.Q"D\;B^"8b'$[`"]'w2@'5}nzB& bkB3CrifhXʘ'w-/KR@{b]MlȭU$Kn RI>hʼԃǹ%ETvxi!!p|1BvRHrvW\|$}V}`nhKw5ڢiu#J6}8| ܞϯOUkt+'oXɔ~^#ϯOvIfxy[qitCڈ9l"qd&-7yLH[[40 LX9]!*8>?Z4FgpQ aY VEyw5ŚXr  ?yBC?<|VCw51dQa}oyRhH%T\[xnouEH ;BѱEt:J-P*z|u HB'&6&()q㾯rc`LADʈ #hnRZodNbU;6zZhdN[P+l}Uz *!V D?g]8%~@xb 5O='8ͯ -$$vzkFg8Q /}ċ  d@^Ŋ:F9u<< 4%}7ؠ>}L:>$QҲ QO"w@(5 LXމQ#?+CUNʠi[ak"#bSF^ج,ɋg (QÂK_f d LҘ5gF;uL[z,ܷ/)y 4JrSuHjI3;KKlPo@!BM4@%-K @"#RĠ !k;D'LSQb݉e(1yԮu%TJy۹_ Hƪ3h>)#8leZʚ1? &5`rhT2zYN^9+Wc~›ePPX53U$O_IUIKJ &W#hXWU)^ kX aCYe<%Đ*aZDXpŤ+yW˯bVvHz~)!{y6]$!J6jo,/4Ih $i;Pm$Z"(~Tj-(JI~BuZ!s[b/3N8 hJ4zCF~ " cdaz9 1GBkf6=!4m^-1Df)rW.摘I:݌.zma[ޒK֊Jwq)UEi5k9͖{[;kdYlOF{^} = jK'uXVք:*0<;rNnrٜxLK$Pe=M卹;3$B >yyv->̓fMSw1a_?@\ rϺB:' 4XU"VN]е_L Fa6N#oճJIMW&SҊ׼l`")6 3EO5PWOcl)?:1TO^`cx> ,@rglld7^ | AL88aHNs6^iS@3ۆ_bM{ͳ}jbLOM44c&xs]Y(][-r4a<I%LFO?9 b~:L \9?,lqoBx4o@B[IxPɀHQNDi#M g{\1ԂGs6'U8Y lWJ &pHnbŏ e &C͖<^ 5deNa!r!Y0 X{XF \όЭ@B53H#N^r'Ν d) Ԙ1|zFQI")RAkJ FGUuDd(YIj:oyY$*[|ĂaL!H C_H! ^лI3TL[CƓE,ߤ&*;CsmUu@a _Neeȯ*P;̍`OOҡ(6A|Xqc|!D215tv‹AQQ;~6N3a$Q>ln7!$9a7>-J N&Q˧w_ / E:jT hS#a6rjXo{ ~kjjFMӚT2p:wJo >nfzHº٢f0H%_mL ?I Bb V ?wrfFFcX*Q t}^p;.U4F'e.@_ &y26\~n밎J#M@WֱĪ03hFG͚+D`k5Zy}g|~T| h0,r1Re $`;s/Tyn uĨYm7-߷v.kT]w%O""Uh%4\Pa^)(rdgW;#0Bt o5ď(` OO?M!}#|tp55@4Y[i8W?ڨ# twNwz/]YNnd <j8bV'׏[u [tx_0fq/Xh]cub%nTUFe7} y7lFkr05_ځ1"Ng3OR bvj,[b`Зg^|O  So>ЍYnQWu DX97,eJ2ONh:˞V93{/޿)bMvJ4/nY}_] *[x-& H]zC ATNxt܂5=)n~;]*⃩@,*I1G=vV rȌZ#o8YD{m /{ԃ nI݉:;u{zC6 .YGĪ!J 2h4 ⤺6ѱSzUߥyJ`[jH1XYV[ێtUmwDg%O[B(u[ srEwMxCaҍTtQ}EӦBSkyn D3sآf+F݈+h_Eh4kr5d5^OLiZceU 7/#@':,B/_>eTse;6fdĶ.r(HjwP&#_EAtG1l.sj=(NHslJй|ӆ@Sy 5eIzWHܘ/@J>MDqruҝgAifwE ִWͰp6U!BUsC̱3 ?ʀͱ2Ys4cP7j=T[݈~y*?YrX>֍n5>_iZ1-~}-9ӯh >@;]_(}U]5w8O (#AN 3gE QwF Xm$wS8qFUÍ\lLjzVաdjI*+V>0.Pdf88JZE@&|)֥e:4s֏^6-KՠV9rC%>;oe~tS2:F s#Eՙ7@'~ AI-m I2̃K1;S֝#q_kӻӗxg$DqyԎ`>, ŕO^ FEUuq|{UJܖhj-`5X —1T׍9`i -tNC-3"H,e)zVr|ڙ $l Gb MFG\jn q$$Dx\ !@?P@,-ZDX=/# BCVQ2 _YeցR7d9nCsgRtyJg$nK)WXI,&ِbic9U?RЖV[^|5rX^(vE1g#@^f_A(1Q)FO%f1ӠVEmŪdגF=M$GS_`/iEE; bnP31A%Vb%4RǨ0Kd0Ni0ETz3S3}iMxSHV&m=BankdoX =? ?sĔ d$'Q*tȣHuxy b]nΆ_*NNv=uT3W?k}@!xiPSwݼo[nR9.sE=@M`ڬlu7ݻq}Կ\88TzJ#adlz"sWSw?Y:" GlިSҖ4v =8l%HtFѹsj(~>--?TŴh*zHAXӹvg:^!.;5@:$>~N]6%%~?̫OuUGMǚndzϺd+E'{U&ۓ}_{ڹ˾ۥjQ:6؃C(#ua\H+6"9GhS,dgt+{Q*܂kn'#?$Mw?O>"b3]lvuӷɻA5/3n3t8SYn/X6"Ejm[ )BT†u3v|]'cE3ba?eťGZg\0ZD{!&:81qF'8YrX"Bb|4U|֊RK߀tMBh>ddPcx_;+u+##Tf? OASآIx$WԯEp} Ѱ:TJ`!`T u9XPșU5 `g(ԿN PzB|Cu$+"<ė(,OsRqk›FȂ8ZqG9g8U+J$7nHm376ڵpPyu{[m,>s0Uϋ0C9޴[S3AKHMΑ`lAjG𣙫)#ZbBpNzV4ah{sPGy6y:~^}^>nܺ9 E(OI+>~=y"_=@ 7=,Zɋ(e"8++Z~ㅻA90wky=ZМO0C84+UkS ڣCMcy$(2! F|Elr`zi0h:v HW ]u ixz 0˗/LTuRT2~ '44oÍ nzf\xg u59g w8l&^G3iӅW z7ĚPUI;>}~'omO l-wo"Uz^5XO0ցOI=?]]|*RnYtD]`)_XK-P +P픷Qо۽&@#K;}\bqdYAOJ$x@-4Z٠fҳ1q?Ե˟]vl3YN0Lk8E/nTf. ;k|7yq3|CTh>" "{,V?XNCvSeqꐫ;J`zKZldLeL4\'=~őG-RzCgkZˊtH'AMǾ5Ny|+#gtȲ;>sźjk:۷UzVzQ\;.P84)iN*CvNJ~ U6|y4j y?'{QzHvԓ`JW Vbx+tS ~_piX&njz&+ʞTl4 .HtYuh@4XȐp`5dt&;QLm=L6}xKAn}t9!]4YW}]>nsok[nE1 !U^S4m7xB^aC .WEb]Y da=LkWdu|lIt4Ċ"bJb/V᳭ (M9)}CÆQ-敽]h|;Rb?Yf#-úl jɷ9$Ϊ7EZ)J$2 +J[?, f&s-cGBMPKS^ߝ%*nI7G˄5&KKyK~Q&5C/0 +X-&V$Yror>!5t\d3b%Yhn?5?AED,YfkN#1~^/. 6$K#3 G0AX+ǟ?78ʆjC*~TPv7m!;Ol @93Ьw GoWWM{4!`u&)ڶD-bM0:Y^yJrr f",Iot]lwB09%xkoWN 17mu5dGvnRD?.׷Ic ~>`6юIDD +n@ ֖T4N)',@}j JV,:hR%DC2!jkDK68{ЎcX]s@X魚F4A|}ҩLa [= мaIr5y){j@۠DwP!i@c!'"ߏ=/SA# P9](zMi3|6LP꙱[nΐ9EP*? EBQ *JԞd{Dm;)SnY-tERm'J1/WZGt  nq榈8(xs[8NpJQ3bH&ŪBJO0cч20h)Wc~|!…Bi$NX;rD#zXߛnKn%/<}wE׮}TsL竁ɶa;E,+D w"v$'Yp/\@Ր9zEUd|##Q3>`|䉺#k:KESͤ=mQ~6btP'a‚ rK^|Ϊ?[)+MY{ |yf!u_7Lcb_Sֳ%b`P62}/"s+XmTS9;w >nf`i =阂HKݞg VO@3υ8EbyѕρvFc#nm^(Pդ墳A'Ċ3K%ns"a| c?qZB$cYx<p{~36j $SXc!NPz1s^4 3 HQq { ?b 91cC:kHy T(",'St"4Fg m sIg' GYdpXFNG|̑^_pC]RJMMLx[p"NqAf}G{9vҿrS$5Rt+&NaC 38لNcna5w 0T}/2`ᚑ eV0wmʦ#zz%'Gږz;^WJ<~?=?Wck%?!H񏫷]5A6,D6C(Ȃ=T3 YMS}s>ACDeIJuUdWau?'dḯ{#r|F70{íoxl5 }dT*I {ct*hA(2jW6ek)@rbӁ#iS+ˢ1o0.mJLbJv©YSM7șFJ'GQL, cKndp~UXQ#&r"7[DꕱP'[Vu~LDѮ[ @j:a#X` Jxp:1M=QWi5^p\rhSy>WG/ V4PF S}ƔL-ZҼ l!=T)>mM7>j@Tј,ز5Diꭳ~{^#D:J/k=KZSCE||/.4_6v㩭$z=Ԧ#[\'An=?? 6Xs<7Dq=Wǐ+jUU}79;|@;mÈhhQGy%}hJCFHTm u~c%X&Cbr//`-`js"+a(cjj{ձ= {)t?ǒFq'瓄\JD%&`@x{/BLCغ 8[f< $eQiE#&D]?Ft,Y|z5Iqd]e_jܘ3VgX^k(?{c2> "L_3FߗPˈQ ^*AU%si24_vd^ZLO\Ԧ0 ?` ]{X Y<%1qɗ9o$)wJWߵD7="7ŊC #^XS-U2 qeǫ+̑ˌ E,}$ƉzH'9LL_Sp <5{&:D3_s_5FJj!d4>vfdI6׫4+P Lby5c/FdXS×PܰȟUdI,䏞jP+P7fUdRw .;Cc 0 ǟY'*X4(/b6Cӧ[38`1u +屻y?SP͕.{@ңE [5ԣŨ|`J+oӇ7͂?;zmIr`6-Zb(֦9| h;w5*;jκ,T 11ZX}T$"ʐN,=ǤYk;ЪUFMMVZamGoPEn'g2ɎN&L3hB8;g-޴P}WB9Lk%’z Ē~}xL߷z]76`'Ԡ\ˡũ| Bٿ?(]"W'bg}i·ヨQ=ɛ z?O;iwg~3tYe/6IKڧ.FuM{w#NJ I<nBa#=^7v^`F&",GՒ<"x:?)_3o[<:;ٳ/ RSLnkzYxRW :u6L z(nA75x*~'( s1LN͟|wˮs?UE6&7Ys) =-3$N\q#YB@ ^O(k4-`Iu/b[S.c~n"Kl4P|J'*ҌIftanHh"ࣞ5*Hťeځ<ԘwpyqΞM"=)-3RbB`8'pLD]JRx+S'^%7F+ {,On[&eV ꛳qf3p{cLazI_ SĝNIwOFʠ>:[?[=~on_\\!1W*gUƵzu/ed1Did ||_B.2Eh& k> LypctpZ?8w߾{!Kn0a>Ӱͱ|e-ږ7IsyƆ {sD7-g5lDS3y0jK}}5uEAN 34Rh w+?R颻^Z6Sl5-ynn֋ؖK?fڢyppVM3 @ 9GQ{^= /|%۝K=S/0٥7_>aL/`jIh6I'w_v>V"/0Wx4P\]1}.|X*Kgvҭ/ B>8_Tv$Yb94{L,'O%<_69, Q&[}ge/7Ҳ۟!dHغ}CمWE5=uɣyܛV>;k,_c?V48FX_=Nx4b߄~ vr5(ΰ}mXK)$~mHo1lՕB5zkt:\52qU ~H^C:15%,>D%1غ9\Sw|e|C5г^g\3%?3Y?It6L8ӦɖK, -=Y{%y˛r7ӁCѭjWkEq{Y:&{7dYQ/hN&Zzw^œ{ս0@g߽È*,;(b/AX~mA &M'3F?(TXq?[]:-Y5,p q&z!je`fz:eo냨.ч$ݪIYbEY/J_F9`ol!S!+5_Yxd*=⟇2;,r' C7b08Q*P:eѓɼXԵV:OʰN}uaʢc=lya|_Qv0"HIpK𞮖KX0ӷ٪%F^݀su6BD'SO,X%ROeWZw_/"DH=.-i~!+s@-w+GR6Jjlnj^NCX̣aDSg[Vzu4vi9Wi,ղ3z FH݅g?_ 3!Vs9sqn&kIhvR5N=u KI?N2'Y!G|7;4 & =§ߡg oȔli7^} 6Na[O(ȫZYFTf$}2EŮg>q[(jՋjK+|vZE~QW@p>2Y;<-?j5~ +,UY=܈ì/^ddog|kL.d\^Dp)BRP7pͱcZ҃Uݗߵ_=LYoD2VVN`tm 5I%*:p&ok@Ac􊑪zr {@ml'h啙=Gj.簐TuqVy%1'hyri'zX#=ɻ5FlZ,pgq@+΃eOvhӳf&Jelٜ^— mKT0mf0]OS@F5nnp飍%K#/N=Ĵf[74gٟU-><|cu8ߡM_o6Y+}W7YE'*B4|l=H Oƹo{~X&E> &2fLeӻTL#KR g.ޫYo٤ԫYnGNMmp%7jAa;WaD!`;_BIyOvPJ!k1RnR!//;C:^mq5? ?g ~YWOz%Z 5J㎎§{FYq=ͨbJ`*%ߞџ @FEApѴ2VᝫaUbKHNq?.C1X 3YԨ4 fʮ?)We̝ |ԗ$AFmQ8* Dhp*E=']&Bd;D|o I]&B {T BJm]Kz,7k8I0P=QB &ฦ@=9ZS]fty*Cǔ:_K[?1+M>O}sxWVFE^BFXe|p.q0 0N;@UI8\JbOH"(E/yQU1&4QbDר*dPP Z3s$~;/FJWW -3㛡pt Po9QnQ ~ndl9|j#Jޠ'C(׬()7*j3>_$Zߢfn|'3ry฾ ;Ul]Z]~A̎H"2,KWU;_HB+v1?żnjs%/ZDϥ\ pQ$P<5b2imd~+!1[uٛ8ѝKBM˘FjG,':y+\\i;gSDU=Ҋ=7V_Ku3l[4`([j1ŕ={ mEMd}wT!a7 Sk&U<4]-QXXFNbUG]Z}?n!5"`$KX͍No&)6N 6=Vnj( "r21oegD8MlY= vZ1Rk'QDw8=Oe0]DG=2qV5vW)3ѵKFJ>, \vGj=3R}lasW| _T.L\{@ޯ5'abnoWqj57:7ehiVq޽U:Z'xZbi7\~5G5hzkE5ArEp=lf FOy.)ڌTwvf56+f $c /lõjŜkB8vmv/ܙH[l Fpvwᚠ2rW}$lTC]oRa@k5!yXRsv=1b%ӍةP'FO %Λ{l(`-5_~׹H۹Y|ƐY6B)*Ӝ :p[95'ߓ7ht>zFFC̕s@T AaZfl&|D0vRgāIkNfO󒚣_H{%u}|MqR8[|̖+(.&3:[V,@!EI{8Eޞ C 9k .aS) J$s0r6zj 7M3g} 4ܳjܥcWyyJ9:Y B/ضN+"$*^B.v}t<1e- 2y3s,uhc6F9 87K|X uk9iEO{^KHyYlPGpAI鉟jTk|.1)C*P[kٛY~o2w[p9)PkF?==xJЕC#5D6[Ә:B@$bqG^! \'-~WoMDE_s/Zȱ (z}޸G3B(gV0 NڲSPzws8$3M)GmVPז3Wg q$<^&͎)tǯ1[%ۋhv\ګɬ֥[ֹ^,e%?ɓMSRd1@>W>OP ,n ^D!@]Ji pĥX!f*7&$ Iآye[VN~sSW8Buw}E% tQjw8gY՞,,k_NXjg2]JrZXS":җ(mDl"rm3|]d2XB271aذ7ʪ!z0QPZii萆 iȐ rJ.ߛah C  ֣I]z UnDL kz_ + Sxyup7yi}vŸC 㿣3*|ymJz}u3zm5o&5+EvXVm:Y[%[yu%7Pzz`:`e/'1iWGlS_xE%w K nH߉en`N VoeW4 sdf97\HZG8gAbͮ[h%T';kDL!@x /C-lhp80 Ų]pyhaW4Q8˸t6:}@3(қVHnBNxDY<:O79L*d#C3:%d,C!n 7c&S1k~Ն,ԯy3ܘ)xH+g"9#|u)xlE@d E"K ~// 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. /* eslint-disable no-restricted-properties */ function $(id) { return document.getElementById(id); } /* eslint-enable no-restricted-properties */ document.addEventListener('DOMContentLoaded', function() { if (cr.isChromeOS) { var keyboardUtils = document.createElement('script'); keyboardUtils.src = 'chrome://credits/keyboard_utils.js'; document.body.appendChild(keyboardUtils); } $('print-link').hidden = false; $('print-link').onclick = function() { window.print(); return false; }; document.addEventListener('keypress', function(e) { // Make the license show/hide toggle when the Enter is pressed. if (e.keyCode == 0x0d && e.target.tagName == 'LABEL') e.target.previousElementSibling.click(); }); }); V{s8O#י$-H ᑂ{Os-yMb|Ɔ\/f,#$xb&yyl?1S2 SETѠ|l Po4c6R1jhs!*h36vxf} |+=ɡct}PI'g,Eau [$8a!ikDp\f,MdRC3wpNq1s%]>sϹ_J9s.DRz Ǻ>VB.&O^l|x߷XRq-{g+vZpVŽ]$deqܻHGeZjyH@fis>hh@ӣCJ^϶M`V*o|U6w:MoÝCvgNP5t+ ⇱ŝՍ?(ݏEnw>VukvEp;ItUru8UJNK=X}p[ܑn[AeMސd|QI- |6?owkmi|bx3gm·Uqo ~;sopíٺi+ |/^8yww o.2l7 5M.&޵nt7eV^qWTܴl1"zi+(Rݖ4g|֐+sP,ٜ Wj-A2=R02 3o>`N{Bs@[8 Y/O1{]~@v ~V SR"8EbLy^y7~zE UϕF~9EKs3=~cU Ƶ @o3NOO_5[ > t\У,=YpC®`t`:o:3,UJP}x#!1-odl/@ϔ@țBی4։))e9 C]0ΣX Ȼ LO$~;4pnϙ"N P%ǷmdziL&B{-eWx0u^^ΖNd~)h( l4pf MR6Qd%h =jDk#ASeaw* Yo۶5&9u{}/8Y-6YH*ߖݑD9v ڑww> btf Nv[q̔rK3J'> jP\sí '`fBJ92NW`ǣ=mV9\ef 1'MYs8?^Na"rt:]8PBQ,Xi" BT1 m~灩^TthO;$؅k{5쓊Z!0Ac4 ;Ej,І];pqDhPPDcm{4;;,mBq)!=#,Sl9+l?8)@x)ʐ1N^rlUH=JYv*nV4gEE."7PWLqbJc ŵQG,$+쭞mNp 7Q/3i1ʛ5YYWm;Q*&a00pd1'̰dng\JzgD=bO徜S3K<)˹Sw1WQ&+8nGfy&>YIڻ:w 3E_U_Ik6f3btzy;/Rey#" S%-ƅ֭9t@%ġp:B9BڕuB_(T<8XU#A3&-?b^v9D'оpLrʍ;ev;A BKQ$iIQIR$Q=f5& WhU m[I3",xlg~teriV*O 㜈Vq~vc{dk˰W߇lR1bms(Ι֤XT9Vrm;;])[R;Ni,mXzZeݩ}_d.iY*rۿVi-_3!o0΄ i./՚ ՒgY[:sz~LRO˛/b_Vg M*^t5E KMUة z 'p}Eؑq92p%KK?$-usK#jl,Kl& mno~qT쥑.b_,^''#n&^wsUqLR<;؊ 7KFڮ}bW۶#?B#ӦͦM7F'0[hBIo%1kxn!ş!\o`O|n>tyJ-Xǝ8}0-n*mcTڮmzV|An=~-n#Lfx2u*OȲ.gccH]Ne=2@\įEk_V[`ۥ .њ@lašg6O,G"wC.M$* V>xO`oMJֺPpF)OYgGا75ã^k.R3^ ΎpV{BiN}èC uh\"2 TC u<5evD]3Zb"TƥiNF|<7ӭ&x7JLrVq?L'ixٲ`|td?ۈoce?|}>Xv>)?OOwչm29}O}␶A4ݕ $i18n{domDistillerTitle}

$i18n{domDistillerTitle}


$i18n{addArticleFailedLabel} $i18n{viewUrlFailedLabel}
$i18n{loadingEntries}
/* 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. */ a:visited { color: orange; } .hidden { visibility: hidden; } // 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 domDistiller = { /** * Callback from the backend with the list of entries to display. * This call will build the entries section of the DOM distiller page, or hide * that section if there are none to display. * @param {!Array} entries The entries. */ onReceivedEntries: function(entries) { $('entries-list-loading').classList.add('hidden'); if (!entries.length) $('entries-list').classList.add('hidden'); var list = $('entries-list'); domDistiller.removeAllChildren(list); for (var i = 0; i < entries.length; i++) { var listItem = document.createElement('li'); var link = document.createElement('a'); var entry_id = entries[i].entry_id; link.setAttribute('id', 'entry-' + entry_id); link.setAttribute('href', '#'); link.innerText = entries[i].title; link.addEventListener('click', function(event) { domDistiller.onSelectArticle(event.target.id.substr("entry-".length)); }, true); listItem.appendChild(link); list.appendChild(listItem); } }, /** * Callback from the backend when adding an article failed. */ onArticleAddFailed: function() { $('add-entry-error').classList.remove('hidden'); }, /** * Callback from the backend when viewing a URL failed. */ onViewUrlFailed: function() { $('view-url-error').classList.remove('hidden'); }, removeAllChildren: function(root) { while(root.firstChild) { root.removeChild(root.firstChild); } }, /** * Sends a request to the browser process to add the URL specified to the list * of articles. */ onAddArticle: function() { $('add-entry-error').classList.add('hidden'); var url = $('article_url').value; chrome.send('addArticle', [url]); }, /** * Sends a request to the browser process to view a distilled version of the * URL specified. */ onViewUrl: function() { $('view-url-error').classList.add('hidden'); var url = $('article_url').value; chrome.send('viewUrl', [url]); }, /** * Sends a request to the browser process to view a distilled version of the * selected article. */ onSelectArticle: function(articleId) { chrome.send('selectArticle', [articleId]); }, /* All the work we do on load. */ onLoadWork: function() { $('list-section').classList.remove('hidden'); $('entries-list-loading').classList.add('hidden'); $('add-entry-error').classList.add('hidden'); $('view-url-error').classList.add('hidden'); $('refreshbutton').addEventListener('click', function(event) { domDistiller.onRequestEntries(); }, false); $('addbutton').addEventListener('click', function(event) { domDistiller.onAddArticle(); }, false); $('viewbutton').addEventListener('click', function(event) { domDistiller.onViewUrl(); }, false); domDistiller.onRequestEntries(); }, onRequestEntries: function() { $('entries-list-loading').classList.remove('hidden'); chrome.send('requestEntries'); }, } document.addEventListener('DOMContentLoaded', domDistiller.onLoadWork); $1 $2
// 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. // On iOS, |distiller_on_ios| was set to true before this script. var distiller_on_ios; if (typeof distiller_on_ios === 'undefined') {distiller_on_ios = false;} function addToPage(html) { var div = document.createElement('div'); div.innerHTML = html; document.getElementById('content').appendChild(div); fillYouTubePlaceholders(); } function fillYouTubePlaceholders() { var placeholders = document.getElementsByClassName('embed-placeholder'); for (var i = 0; i < placeholders.length; i++) { if (!placeholders[i].hasAttribute('data-type') || placeholders[i].getAttribute('data-type') != 'youtube' || !placeholders[i].hasAttribute('data-id')) { continue; } var embed = document.createElement('iframe'); var url = 'http://www.youtube.com/embed/' + placeholders[i].getAttribute('data-id'); embed.setAttribute('class', 'youtubeIframe'); embed.setAttribute('src', url); embed.setAttribute('type', 'text/html'); embed.setAttribute('frameborder', '0'); var parent = placeholders[i].parentElement; var container = document.createElement('div'); container.setAttribute('class', 'youtubeContainer'); container.appendChild(embed); parent.replaceChild(container, placeholders[i]); } } function showLoadingIndicator(isLastPage) { document.getElementById('loadingIndicator').className = isLastPage ? 'hidden' : 'visible'; } // Sets the title. function setTitle(title) { var holder = document.getElementById('titleHolder'); holder.textContent = title; document.title = title; } // Set the text direction of the document ('ltr', 'rtl', or 'auto'). function setTextDirection(direction) { document.body.setAttribute('dir', direction); } // Maps JS Font Family to CSS class and then changes body class name. // CSS classes must agree with distilledpage.css. function useFontFamily(fontFamily) { var cssClass; if (fontFamily == "serif") { cssClass = "serif"; } else if (fontFamily == "monospace") { cssClass = "monospace"; } else { cssClass = "sans-serif"; } // Relies on the classname order of the body being Theme class, then Font // Family class. var themeClass = document.body.className.split(" ")[0]; document.body.className = themeClass + " " + cssClass; } // Maps JS theme to CSS class and then changes body class name. // CSS classes must agree with distilledpage.css. function useTheme(theme) { var cssClass; if (theme == "sepia") { cssClass = "sepia"; } else if (theme == "dark") { cssClass = "dark"; } else { cssClass = "light"; } // Relies on the classname order of the body being Theme class, then Font // Family class. var fontFamilyClass = document.body.className.split(" ")[1]; document.body.className = cssClass + " " + fontFamilyClass; updateToolbarColor(); } function updateToolbarColor() { // Relies on the classname order of the body being Theme class, then Font // Family class. var themeClass = document.body.className.split(" ")[0]; var toolbarColor; if (themeClass == "sepia") { toolbarColor = "#BF9A73"; } else if (themeClass == "dark") { toolbarColor = "#1A1A1A"; } else { toolbarColor = "#F5F5F5"; } document.getElementById('theme-color').content = toolbarColor; } function useFontScaling(scaling) { pincher.useFontScaling(scaling); } function maybeSetWebFont() { // On iOS, the web fonts block the rendering until the resources are // fetched, which can take a long time on slow networks. // In Blink, it times out after 3 seconds and uses fallback fonts. // See crbug.com/711650 if (distiller_on_ios) return; var e = document.createElement('link'); e.href = 'https://fonts.googleapis.com/css?family=Roboto'; e.rel = 'stylesheet'; e.type = 'text/css'; document.head.appendChild(e); } // Add a listener to the "View Original" link to report opt-outs. document.getElementById('closeReaderView').addEventListener('click', function(e) { if (distiller) { distiller.closePanel(true); } }, true); updateToolbarColor(); maybeSetWebFont(); var pincher = (function() { 'use strict'; // When users pinch in Reader Mode, the page would zoom in or out as if it // is a normal web page allowing user-zoom. At the end of pinch gesture, the // page would do text reflow. These pinch-to-zoom and text reflow effects // are not native, but are emulated using CSS and JavaScript. // // In order to achieve near-native zooming and panning frame rate, fake 3D // transform is used so that the layer doesn't repaint for each frame. // // After the text reflow, the web content shown in the viewport should // roughly be the same paragraph before zooming. // // The control point of font size is the html element, so that both "em" and // "rem" are adjusted. // // TODO(wychen): Improve scroll position when elementFromPoint is body. var pinching = false; var fontSizeAnchor = 1.0; var focusElement = null; var focusPos = 0; var initClientMid; var clampedScale = 1; var lastSpan; var lastClientMid; var scale = 1; var shiftX; var shiftY; // The zooming speed relative to pinching speed. var FONT_SCALE_MULTIPLIER = 0.5; var MIN_SPAN_LENGTH = 20; // The font size is guaranteed to be in px. var baseSize = parseFloat(getComputedStyle(document.documentElement).fontSize); var refreshTransform = function() { var slowedScale = Math.exp(Math.log(scale) * FONT_SCALE_MULTIPLIER); clampedScale = Math.max(0.5, Math.min(2.0, fontSizeAnchor * slowedScale)); // Use "fake" 3D transform so that the layer is not repainted. // With 2D transform, the frame rate would be much lower. document.body.style.transform = 'translate3d(' + shiftX + 'px,' + shiftY + 'px, 0px)' + 'scale(' + clampedScale/fontSizeAnchor + ')'; }; function saveCenter(clientMid) { // Try to preserve the pinching center after text reflow. // This is accurate to the HTML element level. focusElement = document.elementFromPoint(clientMid.x, clientMid.y); var rect = focusElement.getBoundingClientRect(); initClientMid = clientMid; focusPos = (initClientMid.y - rect.top) / (rect.bottom - rect.top); } function restoreCenter() { var rect = focusElement.getBoundingClientRect(); var targetTop = focusPos * (rect.bottom - rect.top) + rect.top + document.body.scrollTop - (initClientMid.y + shiftY); document.body.scrollTop = targetTop; } function endPinch() { pinching = false; document.body.style.transformOrigin = ''; document.body.style.transform = ''; document.documentElement.style.fontSize = clampedScale * baseSize + "px"; restoreCenter(); var img = document.getElementById('fontscaling-img'); if (!img) { img = document.createElement('img'); img.id = 'fontscaling-img'; img.style.display = 'none'; document.body.appendChild(img); } img.src = "/savefontscaling/" + clampedScale; } function touchSpan(e) { var count = e.touches.length; var mid = touchClientMid(e); var sum = 0; for (var i = 0; i < count; i++) { var dx = (e.touches[i].clientX - mid.x); var dy = (e.touches[i].clientY - mid.y); sum += Math.hypot(dx, dy); } // Avoid very small span. return Math.max(MIN_SPAN_LENGTH, sum/count); } function touchClientMid(e) { var count = e.touches.length; var sumX = 0; var sumY = 0; for (var i = 0; i < count; i++) { sumX += e.touches[i].clientX; sumY += e.touches[i].clientY; } return {x: sumX/count, y: sumY/count}; } function touchPageMid(e) { var clientMid = touchClientMid(e); return {x: clientMid.x - e.touches[0].clientX + e.touches[0].pageX, y: clientMid.y - e.touches[0].clientY + e.touches[0].pageY}; } return { handleTouchStart: function(e) { if (e.touches.length < 2) return; e.preventDefault(); var span = touchSpan(e); var clientMid = touchClientMid(e); if (e.touches.length > 2) { lastSpan = span; lastClientMid = clientMid; refreshTransform(); return; } scale = 1; shiftX = 0; shiftY = 0; pinching = true; fontSizeAnchor = parseFloat(getComputedStyle(document.documentElement).fontSize) / baseSize; var pinchOrigin = touchPageMid(e); document.body.style.transformOrigin = pinchOrigin.x + 'px ' + pinchOrigin.y + 'px'; saveCenter(clientMid); lastSpan = span; lastClientMid = clientMid; refreshTransform(); }, handleTouchMove: function(e) { if (!pinching) return; if (e.touches.length < 2) return; e.preventDefault(); var span = touchSpan(e); var clientMid = touchClientMid(e); scale *= touchSpan(e) / lastSpan; shiftX += clientMid.x - lastClientMid.x; shiftY += clientMid.y - lastClientMid.y; refreshTransform(); lastSpan = span; lastClientMid = clientMid; }, handleTouchEnd: function(e) { if (!pinching) return; e.preventDefault(); var span = touchSpan(e); var clientMid = touchClientMid(e); if (e.touches.length >= 2) { lastSpan = span; lastClientMid = clientMid; refreshTransform(); return; } endPinch(); }, handleTouchCancel: function(e) { if (!pinching) return; endPinch(); }, reset: function() { scale = 1; shiftX = 0; shiftY = 0; clampedScale = 1; document.documentElement.style.fontSize = clampedScale * baseSize + "px"; }, status: function() { return { scale: scale, clampedScale: clampedScale, shiftX: shiftX, shiftY: shiftY }; }, useFontScaling: function(scaling) { saveCenter({x: window.innerWidth/2, y: window.innerHeight/2}); shiftX = 0; shiftY = 0; document.documentElement.style.fontSize = scaling * baseSize + "px"; clampedScale = scaling; restoreCenter(); } }; }()); window.addEventListener('touchstart', pincher.handleTouchStart, false); window.addEventListener('touchmove', pincher.handleTouchMove, false); window.addEventListener('touchend', pincher.handleTouchEnd, false); window.addEventListener('touchcancel', pincher.handleTouchCancel, 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. // Applies DomDistillerJs to the content of the page and returns a // DomDistillerResults (as a javascript object/dict). (function(options, stringify_output) { try { function initialize() { // This include will be processed at build time by grit. // Note: this is not behind a single-line comment because the // first line of the file is source code (so the first line would be // skipped) instead of a licence header. // clang-format off (function () {var $gwt_version = "2.7.0";var $wnd = window;var $doc = $wnd.document;var $moduleName, $moduleBase;var $stats = $wnd.__gwtStatsEvent ? function(a) {$wnd.__gwtStatsEvent(a)} : null;var $strongName = 'A7F9C2E6B71EF42917861839A0FBE645';var aa=2147483647,ba={3:1,12:1},ca={3:1,15:1,12:1},da={3:1,4:1},ea={3:1,5:1,6:1,4:1},ga={10:1,18:1,3:1,11:1,9:1},h={3:1,5:1,14:1,6:1,4:1,13:1},ha={46:1},ka={25:1},la={3:1,31:1},ma={3:1,11:1,9:1,29:1},na={3:1,5:1,4:1},_,oa,pa={};function qa(){}function ra(a){function b(){}b.prototype=a||{};return new b}function k(){} function n(a,b,c){var d=pa[a],e=d instanceof Array?d[0]:null;d&&!e?_=d:(_=pa[a]=b?ra(pa[b]):{},_.cM=c,_.constructor=_,!b&&(_.tM=qa));for(d=3;d>>0).toString(16);return a+b};_.toString=function(){return this.tS()};Ua={3:1,220:1,11:1,2:1};!Array.isArray&&(Array.isArray=function(a){return"[object Array]"===Object.prototype.toString.call(a)});function Va(a){return a.toString?a.toString():"[JavaScriptObject]"}function xa(a){return!Array.isArray(a)&&a.tM===qa}function r(a,b){return null!=a&&(va(a)&&!!Ua[b]||a.cM&&!!a.cM[b])} function ya(a){return Array.isArray(a)&&a.tM===qa}function va(a){return"string"===typeof a}function s(a){return null==a?null:a}function Wa(a){return~~Math.max(Math.min(a,aa),-2147483648)}var Ua;function Xa(a){if(null==a.n)if(a.B()){var b=a.c;b.C()?a.n="["+b.k:b.B()?a.n="["+b.w():a.n="[L"+b.w()+";";a.b=b.v()+"[]";a.j=b.A()+"[]"}else{var b=a.g,c=a.d,c=c.split("/");a.n=Ya(".",[b,Ya("$",c)]);a.b=Ya(".",[b,Ya(".",c)]);a.j=c[c.length-1]}}function Ta(a){Xa(a);return a.n}function Za(a){Xa(a);return a.j} function $a(){this.i=jb++;this.a=this.k=this.b=this.d=this.g=this.j=this.n=null}function kb(a){var b;b=new $a;b.n="Class$"+(a?"S"+a:""+b.i);b.b=b.n;b.j=b.n;return b}function t(a){var b;b=kb(a);lb(a,b);return b}function u(a,b){var c;c=kb(a);lb(a,c);c.f=b?8:0;c.e=b;return c}function mb(){var a;a=kb(null);a.f=2;return a}function w(a,b){var c=a.a=a.a||[];return c[b]||(c[b]=a.u(b))}function Ya(a,b){for(var c=0;!b[c]||""==b[c];)c++;for(var d=b[c++];ca||a>=b)throw new Mc("Index: "+a+", Size: "+b);}function Nc(a){if(null==a)throw new Oc;} function Pc(a,b){if(0>a||a>b)throw new Mc("Index: "+a+", Size: "+b);}function Qc(a,b){var c,d,e,f;a=""+a;c=new Rc;for(d=f=0;db?"ie10":-1!=a.indexOf("msie")&&9<=b&&11>b?"ie9":-1!=a.indexOf("msie")&&8<=b&&11>b?"ie8":-1!=a.indexOf("gecko")||11<=b?"gecko1_8":"unknown";if("safari"!==a)throw new Qe(a);}n(59,12,ba);t(59); n(22,59,ba);t(22);function Qe(a){this.e=""+("Possible problem with your *.gwt.xml module file.\nThe compile time user.agent value (safari) does not match the runtime user.agent value ("+a+").\nExpect more errors.");cc(this,this.e)}n(100,22,ba,Qe);t(100);n(60,1,{});_.tS=function(){return this.a};t(60);function Re(){cc(this,this.e)}function Mc(a){ob.call(this,a)}n(30,19,ca,Re,Mc);t(30);function Se(){cc(this,this.e)}n(180,30,ca,Se);t(180);function Kc(a){ob.call(this,a)}n(36,19,ca,Kc);t(36); function Te(){Te=k;Ue=new Ve(!1);We=new Ve(!0)}function Ve(a){this.a=a}n(47,1,{3:1,47:1,11:1},Ve);_.t=function(a){var b=this.a;return b==a.a?0:b?1:-1};_.eQ=function(a){return r(a,47)&&a.a==this.a};_.hC=function(){return this.a?1231:1237};_.tS=function(){return""+this.a};_.a=!1;var Ue,We;t(47);function Xe(a){this.a=a}n(37,1,{3:1,37:1,11:1},Xe);_.t=function(a){return this.a-a.a};_.eQ=function(a){return r(a,37)&&a.a==this.a};_.hC=function(){return this.a};_.tS=function(){return String.fromCharCode(this.a)}; _.a=0;var Ye=t(37);function Ze(){Ze=k;$e=E(Ye,ea,37,128,0)}var $e;n(75,1,{3:1,75:1});t(75);function af(a){ob.call(this,a)}n(23,19,{3:1,15:1,23:1,12:1},af);t(23);function bf(){cc(this,this.e)}n(181,19,ca,bf);t(181);function cf(a){this.a=a}function df(a){var b,c;return-129a?(b=a+128,c=(ef(),ff)[b],!c&&(c=ff[b]=new cf(a)),c):new cf(a)}n(38,75,{3:1,11:1,38:1,75:1},cf);_.t=function(a){var b=this.a;a=a.a;return ba?1:0};_.eQ=function(a){return r(a,38)&&a.a==this.a};_.hC=function(){return this.a}; _.tS=function(){return""+this.a};_.a=0;var gf=t(38);function ef(){ef=k;ff=E(gf,ea,38,256,0)}var ff;function hf(a,b){return a>10&1023)&65535)+String.fromCharCode(d)):b=String.fromCharCode(b&65535);return a.lastIndexOf(b,c)}function sf(a,b,c,d,e){if(null==c)throw new Oc;if(0>b||0>d||0>=e||b+e>a.length||d+e>c.length)return!1;a=a.substr(b,e);c=c.substr(d,e);return a===c}function zf(a){var b=(160).toString(16),b="\\u"+"0000".substring(b.length)+b;return a.replace(RegExp(b,"g"),String.fromCharCode(32))} function Af(a,b){var c;c=Xf("");return a.replace(RegExp(b,"g"),c)}function Yf(a,b){var c;c=Xf("");return a.replace(RegExp(b),c)} function lg(a,b){for(var c=RegExp(b,"g"),d=[],e=0,f=a,g=null;;){var l=c.exec(f);if(null==l||""==f){d[e]=f;break}else d[e]=f.substring(0,l.index),f=f.substring(l.index+l[0].length,f.length),c.lastIndex=0,g==f&&(d[e]=f.substring(0,1),f=f.substring(1)),g=f,e++}if(0d&&(a[d]=null);return a};_.tS=function(){return Xg(this)};t(211);function Yg(a,b){var c,d,e;c=b.W();e=b.X();d=a.P(c);return!(s(e)===s(d)||null!=e&&ua(e,d))||null==d&&!a.N(c)?!1:!0} function Zg(a,b){var c,d,e;for(d=a.O().D();d.Q();)if(c=d.R(),e=c.W(),s(b)===s(e)||null!=b&&ua(b,e))return c;return null}function $g(a,b){return b===a?"(this Map)":""+b}function ah(a){return a?a.X():null}n(210,1,ha);_.M=function(a){return Yg(this,a)};_.N=function(a){return!!Zg(this,a)};_.eQ=function(a){var b;if(a===this)return!0;if(!r(a,46)||this.J()!=a.J())return!1;for(b=a.O().D();b.Q();)if(a=b.R(),!this.M(a))return!1;return!0};_.P=function(a){return ah(Zg(this,a))};_.hC=function(){return bh(this.O())}; _.J=function(){return this.O().J()};_.tS=function(){var a,b,c,d;d=new Ug("{");a=!1;for(c=this.O().D();c.Q();)b=c.R(),a?d.a+=", ":a=!0,d.a+=$g(this,b.W()),d.a+="\x3d",d.a+=$g(this,b.X());d.a+="}";return d.a};t(210);function ch(a,b){return va(b)?G(a,b):!!dh(a.a,b)}function eh(a,b){return va(b)?H(a,b):ah(dh(a.a,b))}function H(a,b){return null==b?ah(dh(a.a,null)):a.c.eb(b)}function G(a,b){return null==b?!!dh(a.a,null):void 0!==a.c.eb(b)}function fh(a,b,c){return va(b)?L(a,b,c):gh(a.a,b,c)} function L(a,b,c){return null==b?gh(a.a,null,c):a.c.hb(b,c)}function hh(a){ih();a.a=jh.bb();a.a.b=a;a.c=jh.cb();a.c.b=a;a.b=0;kh(a)}n(113,210,ha);_.N=function(a){return ch(this,a)};_.O=function(){return new lh(this)};_.P=function(a){return eh(this,a)};_.J=function(){return this.b};_.b=0;t(113);n(212,211,ka);_.eQ=function(a){if(a===this)a=!0;else if(r(a,25)&&a.J()==this.J())a:{var b;Nc(a);for(b=a.D();b.Q();)if(a=b.R(),!this.H(a)){a=!1;break a}a=!0}else a=!1;return a};_.hC=function(){return bh(this)}; t(212);function lh(a){this.a=a}n(63,212,ka,lh);_.H=function(a){return r(a,24)?Yg(this.a,a):!1};_.D=function(){return new mh(this.a)};_.J=function(){return this.a.b};t(63);function nh(a){if(a.a.Q())return!0;if(a.a!=a.b)return!1;a.a=a.c.a._();return a.a.Q()}function oh(a){if(a._gwt_modCount!=a.c._gwt_modCount)throw new ph;return y(nh(a)),a.a.R()}function mh(a){this.c=a;this.a=this.b=this.c.c._();this._gwt_modCount=a._gwt_modCount}n(64,1,{},mh);_.Q=function(){return nh(this)};_.R=function(){return oh(this)}; t(64);n(213,211,{31:1});_.S=function(){throw new Vg("Add not supported on this list");};_.F=function(a){this.S(this.J(),a);return!0};_.eQ=function(a){var b,c,d;if(a===this)return!0;if(!r(a,31)||this.J()!=a.J())return!1;d=a.D();for(b=this.D();b.Q();)if(a=b.R(),c=d.R(),!(s(a)===s(c)||null!=a&&ua(a,c)))return!1;return!0};_.hC=function(){var a,b,c;c=1;for(b=this.D();b.Q();)a=b.R(),c=31*c+(null!=a?Ga(a):0),c=~~c;return c};_.D=function(){return new M(this)}; _.U=function(){throw new Vg("Remove not supported on this list");};t(213);function qh(a){if(-1==a.c)throw new bf;a.d.U(a.c);a.b=a.c;a.c=-1}function M(a){this.d=a}n(7,1,{},M);_.Q=function(){return this.bb)throw new Mc("fromIndex: "+b+" \x3c 0");if(c>d)throw new Mc("toIndex: "+c+" \x3e size "+d);if(b>c)throw new af("fromIndex: "+b+" \x3e toIndex: "+c);this.c=a;this.a=b;this.b=c-b}n(50,213,{31:1},th);_.S=function(a,b){Pc(a,this.b);uh(this.c,this.a+a,b);++this.b};_.T=function(a){return sh(this,a)};_.U=function(a){z(a,this.b);a=this.c.U(this.a+a);--this.b;return a};_.J=function(){return this.b};_.a=0;_.b=0;t(50); function vh(a){a=new mh((new lh(a.a)).a);return new wh(a)}function xh(a){this.a=a}n(65,212,ka,xh);_.H=function(a){return ch(this.a,a)};_.D=function(){return vh(this)};_.J=function(){return this.a.b};t(65);function wh(a){this.a=a}n(114,1,{},wh);_.Q=function(){return nh(this.a)};_.R=function(){return oh(this.a).W()};t(114);function yh(a,b){var c;c=a.d;a.d=b;return c}n(48,1,{48:1,24:1});_.eQ=function(a){return r(a,24)?zh(this.c,a.W())&&zh(this.d,a.X()):!1};_.W=function(){return this.c};_.X=function(){return this.d}; _.hC=function(){return Ah(this.c)^Ah(this.d)};_.Y=function(a){return yh(this,a)};_.tS=function(){return this.c+"\x3d"+this.d};t(48);function Bh(a,b){this.c=a;this.d=b}n(49,48,{48:1,49:1,24:1},Bh);t(49);n(216,1,{24:1});_.eQ=function(a){return r(a,24)?zh(this.W(),a.W())&&zh(this.X(),a.X()):!1};_.hC=function(){return Ah(this.W())^Ah(this.X())};_.tS=function(){return this.W()+"\x3d"+this.X()};t(216);function Ch(a,b){var c;c=Dh(a,b.W());return!!c&&zh(c.d,b.X())}n(218,210,ha); _.M=function(a){return Ch(this,a)};_.N=function(a){return!!Dh(this,a)};_.O=function(){return new Eh(this)};_.P=function(a){return ah(Dh(this,a))};t(218);function Eh(a){this.a=a}n(97,212,ka,Eh);_.H=function(a){return r(a,24)&&Ch(this.a,a)};_.D=function(){return new Fh(this.a)};_.J=function(){return this.a.c};t(97);function Gh(a){a=new Fh((new Hh(a.a)).a);return new Ih(a)}function Jh(a){this.a=a}n(192,212,ka,Jh);_.H=function(a){return!!Dh(this.a,a)};_.D=function(){return Gh(this)};_.J=function(){return this.a.c}; t(192);function Ih(a){this.a=a}n(193,1,{},Ih);_.Q=function(){return this.a.a.Q()};_.R=function(){return this.a.a.R().W()};t(193);function Kh(a,b){var c;c=Lh(a,b);try{return y(c.b!=c.d.c),c.c=c.b,c.b=c.b.a,++c.a,c.c.c}catch(d){d=Cc(d);if(r(d,55))throw new Mc("Can't get element "+b);throw Dc(d);}}n(214,213,{31:1});_.S=function(a,b){var c;c=Lh(this,a);Mh(c.d,b,c.b.b,c.b);++c.a;c.c=null};_.T=function(a){return Kh(this,a)};_.D=function(){return Lh(this,0)}; _.U=function(a){var b,c;b=Lh(this,a);try{return c=(y(b.b!=b.d.c),b.c=b.b,b.b=b.b.a,++b.a,b.c.c),Nh(b),c}catch(d){d=Cc(d);if(r(d,55))throw new Mc("Can't remove element "+a);throw Dc(d);}};t(214);function Oh(a){a.b=E(nb,da,1,0,3)}function uh(a,b,c){Pc(b,a.b.length);a.b.splice(b,0,c)}function P(a,b){a.b[a.b.length]=b;return!0}function Ph(a,b){var c;c=b.K();if(0==c.length)return!1;Oe(c,0,a.b,a.b.length,c.length,!1);return!0}function N(a,b){z(b,a.b.length);return a.b[b]} function Qh(a,b){for(var c=0;cd&&(b[d]=null);return b}function Q(){Oh(this)}function Uh(a){Oh(this);a=Je(a.b,a.b.length);Oe(a,0,this.b,0,a.length,!1)}n(8,213,la,Q,Uh);_.S=function(a,b){uh(this,a,b)};_.F=function(a){return P(this,a)}; _.G=function(a){return Ph(this,a)};_.H=function(a){return-1!=Qh(this,a)};_.T=function(a){return N(this,a)};_.I=function(){return 0==this.b.length};_.U=function(a){return Rh(this,a)};_.J=function(){return this.b.length};_.K=function(){return Je(this.b,this.b.length)};_.L=function(a){return Th(this,a)};t(8); function Vh(a,b,c,d,e,f){var g,l,m;if(7>d-c)for(a=c,g=a+1;ga&&0>1),Vh(b,a,l,m,-e,f),Vh(b,a,m,g,-e,f),0>=f.Z(a[m-1],a[m]))for(;c=g||e=f.Z(a[e],a[l])?b[c++]=a[e++]:b[c++]=a[l++]}function bh(a){var b,c;c=0;for(b=a.D();b.Q();)a=b.R(),c+=null!=a?Ga(a):0,c=~~c;return c}function Wh(){Wh=k;Xh=new Yh}var Xh; function Zh(a,b){Nc(a);Nc(b);return va(a)?a==b?0:a=a.b>>1)for(d=a.c,c=a.b;c>b;--c)d=d.b;else for(d=a.a.a,c=0;ca||a>=b)throw new Se;}n(124,213,la);_.S=function(a,b){hk(a,this.a.b.length+1);uh(this.a,a,b)};_.F=function(a){return P(this.a,a)};_.G=function(a){return Ph(this.a,a)};_.H=function(a){return-1!=Qh(this.a,a)};_.T=function(a){return hk(a,this.a.b.length),N(this.a,a)};_.I=function(){return 0==this.a.b.length}; _.D=function(){return new M(this.a)};_.U=function(a){return hk(a,this.a.b.length),this.a.U(a)};_.J=function(){return this.a.b.length};_.K=function(){var a=this.a;return Je(a.b,a.b.length)};_.L=function(a){return Th(this.a,a)};_.tS=function(){return Xg(this.a)};t(124);function ik(a){var b;b=a.a.b.length;if(0c?0:1;d=d.a[c]}return null}function kk(a,b,c,d,e,f,g,l){var m;if(d){(m=d.a[0])&&kk(a,b,c,m,e,f,g,l);m=d.c;var q,v;c.kb()&&(q=Zh(m,e),0>q||!f&&0==q)||c.lb()&&(v=Zh(m,g),0e?0:1;b.a[e]=lk(a,b.a[e],c,d);mk(b.a[e])&&(mk(b.a[1-e])?(b.b=!0,b.a[0].b=!1,b.a[1].b=!1):mk(b.a[e].a[e])?b=nk(b,1-e):mk(b.a[e].a[1-e])&&(b=(f=1-(1-e),b.a[f]=nk(b.a[f],f),nk(b,1-e))))}else return c;return b}function mk(a){return!!a&&a.b}function nk(a,b){var c,d;c=1-b;d=a.a[c];a.a[c]=d.a[b];d.a[b]=a;a.b=!0;d.b=!1;return d}function ok(){var a=null;this.b=null;!a&&(a=(Wh(),Wh(),Xh));this.a=a} n(96,218,{3:1,46:1},ok);_.O=function(){return new Hh(this)};_.J=function(){return this.c};_.c=0;t(96);function Fh(a){var b=(pk(),qk),c;c=new Q;kk(a,c,b,a.b,null,!1,null,!1);this.a=new rh(c,0)}n(74,1,{},Fh);_.Q=function(){return this.a.Q()};_.R=function(){return this.a.R()};t(74);function Hh(a){this.a=a}n(98,97,ka,Hh);t(98);function rk(a,b){Bh.call(this,a,b);this.a=E(sk,da,58,2,0);this.b=!0}n(58,49,{48:1,49:1,24:1,58:1},rk);_.b=!1;var sk=t(58);function tk(){}n(188,1,{},tk); _.tS=function(){return"State: mv\x3d"+this.c+" value\x3d"+this.d+" done\x3d"+this.a+" found\x3d"+this.b};_.a=!1;_.b=!1;_.c=!1;t(188);function pk(){pk=k;qk=new uk("All",0);vk=new wk;xk=new yk;zk=new Ak}function uk(a,b){C.call(this,a,b)}n(29,9,ma,uk);_.kb=function(){return!1};_.lb=function(){return!1};var qk,vk,xk,zk,Bk=u(29,function(){pk();return D(w(Bk,1),ea,29,0,[qk,vk,xk,zk])});function wk(){C.call(this,"Head",1)}n(189,29,ma,wk);_.lb=function(){return!0};u(189,null); function yk(){C.call(this,"Range",2)}n(190,29,ma,yk);_.kb=function(){return!0};_.lb=function(){return!0};u(190,null);function Ak(){C.call(this,"Tail",3)}n(191,29,ma,Ak);_.kb=function(){return!0};u(191,null);function Ck(a){this.a=new ok;Wg(this,a)}n(90,212,{3:1,25:1},Ck);_.F=function(a){var b=this.a,c=(Te(),Ue);a=new rk(a,c);c=new tk;b.b=lk(b,b.b,a,c);c.b||++b.c;b.b.b=!1;return null==c.d};_.H=function(a){return!!Dh(this.a,a)};_.D=function(){return Gh(new Jh(this.a))};_.J=function(){return this.a.c}; t(90); function Dk(a){var b;if(!(0Fk.Bb(f)&&(f=c.replace(RegExp("[^\\|\\-]*[\\|\\-](.*)","gi"),"$1"));else if(-1!=f.indexOf(": "))f=c.replace(RegExp(".*:(.*)","gi"),"$1"),3>Fk.Bb(f)&&(f=c.replace(RegExp("[^:]*[:](.*)","gi"),"$1"));else if(e&& (150f.length)){f=e.getElementsByTagName("H1");e="";for(d=0;d=Fk.Bb(f)&&(f=c);c=f}else c="";dk(b,c);p==p&&dk(a.a,$doc.title)}}function Hk(a){var b,c;this.b=a;this.a=new gk;this.e=(b={},b[6]=[],b);this.d=(c={},c);b=V();this.f=new Ik(a,this.e);a=V()-b;if(void 0==a)throw new TypeError;this.e[1]=a;this.g=""}n(102,1,{},Hk);t(102);function Jk(){}n(103,1,{},Jk);t(103); function Kk(a){var b,c,d,e,f,g,l,m,q,v,I,pb,Aa,li,kn,mi,ia,mf,nf,ln,mn;v=V();var nn=$doc.documentElement.textContent,on,pn;U();Fk=(on=RegExp("[\\u3040-\\uA4CF]","g"),pn=RegExp("[\\uAC00-\\uD7AF]","g"),on.test(nn)?new Lk:pn.test(nn)?new Mk:new Nk);m=(li={},li[10]=[],li);c=new Hk($doc.documentElement);var sn=(Dk(c),Kh(c.a,0));if(void 0==sn)throw new TypeError;m[1]=sn;var oi;if(void 0!=a[2]){if(void 0===a[2])throw new TypeError;oi=a[2]}else oi=0;Ok=oi;W("DomDistiller debug level: "+Ok);b=(kn={},kn); var pi;if(pi=void 0!=a[1]){if(void 0===a[1])throw new TypeError;pi=a[1]}var tn=pi,nc,Vd,qi,Wd,Vc,ri,Xd,un,of,Wc;Vc=V();ri=new Jk;Xd=new Pk;un=c.b.querySelectorAll('meta[name\x3d"viewport"][content*\x3d"width\x3ddevice-width"]');of=new Qk(Xd);of.i=0=ae.c?!ce||0.555556>=ce.c?16>=ae.d?!be||15>=be.d?!ce||4>=ce.d?ab=!1:ab=!0:ab=!0:ab=!0:40>=ae.d?!be||17>=be.d?ab=!1:ab=!0:ab=!0:ab=!1,dl(ae, ab));O=uf}Zk(J,O,"Classification Complete");var ir=(el(),fl),Ai,In,Bi,vf,Jn,wf,sb;Ai=!1;sb=new M(J.a);a:for(;sb.bDi.b.length)O=!1;else{yf=!1;de=new rh(Di,0);for(tb=de.R();de.Q();)if(Ea=tb,tb=de.R(),S(Ea.b,"de.l3s.boilerpipe/HEADING")&&!(S(Ea.b,"STRICTLY_NOT_CONTENT")||S(tb.b,"STRICTLY_NOT_CONTENT")||S(Ea.b,"de.l3s.boilerpipe/TITLE")||S(tb.b,"de.l3s.boilerpipe/TITLE")))if(tb.a){yf=!0;Nn=Ea.a;Xk(Ea,tb);tb=Ea;de.V();var On=Ea;S(On.b,"de.l3s.boilerpipe/HEADING")&&On.b.a.c.ib("de.l3s.boilerpipe/HEADING");Nn||R(Ea.b,"BOILERPLATE_HEADING_FUSED")}else Ea.a&&(yf=!0,dl(Ea,!1));O=yf}Zk(J, O,"HeadingFusion");O=jl((kl(),ll),J);Zk(J,O,"BlockProximityFusion: Distance 1");var jr=(ml(),nl),Ei,bb,Fi,Un;Un=J.a;Ei=!1;for(bb=new M(Un);bb.bYc.b.length)O=!1;else{Hi=-1;ub=null;Gi=0;ee=-1;for(wb=new M(Yc);wb.bHi&&(ub=Ka,Hi=Ii,ee=Gi)),++Gi;for(vb=new M(Yc);vb.bEf&&S(db.b,"de.l3s.boilerpipe/MIGHT_BE_CONTENT")&&S(db.b,"de.l3s.boilerpipe/LI")&&0==db.c?(dl(db,!0),Ni=!0):Ef=aa;O=Ni;Zk(J,O,"List at end filter");var nr=c.d,Oi,Ff,Pi,Eb;Ff=0;for(Eb=new M(J.a);Eb.bOk||(Mf?W("FINAL SCORE: "+ie+" : "+A(Mf,"src")):W("Null image attempting to be scored!"));he=ie}else he=0;26<=he&&(!ge||Ri=je.a.b.length)&&(Ti=je.a.b.length-1),jo=Ui.p,Ui.p=Ma,ke.p=Ma,Ma=jo)):Ma||(Ma=Nf.p);var ko=V()-Vc;if(void 0==ko)throw new TypeError;c.e[3]=ko;Vc=V();var Vi,Nb,dd;dd=new Tg;for(Nb=new M(nc.a.a);Nb.b=no[6].length)throw new RangeError;Vd=no[6][Wd];if(void 0===Vd[1])throw new TypeError; var tr="Timing: "+Vd[1]+" \x3d ";if(void 0===Vd[2])throw new TypeError;W(tr+Vd[2])}var oo=c.e;if(void 0===oo[1])throw new TypeError;var ur="Timing: MarkupParsingTime \x3d "+oo[1]+"\nTiming: DocumentConstructionTime \x3d ",po=c.e;if(void 0===po[2])throw new TypeError;var vr=ur+po[2]+"\nTiming: ArticleProcessingTime \x3d ",qo=c.e;if(void 0===qo[3])throw new TypeError;var wr=vr+qo[3]+"\nTiming: FormattingTime \x3d ",ro=c.e;if(void 0===ro[4])throw new TypeError;W(wr+ro[4])}if(void 0==qi)throw new TypeError; b[1]=qi;if(void 0==b)throw new TypeError;m[2]=b;var so=((null==c.g||!c.g.length)&&(c.g="auto"),c.g);if(void 0==so)throw new TypeError;m[9]=so;for(Aa=new M(c.c);Aa.bZf.b.b.length)){for(var T=Zf.b,zr=0>Zf.a,bg=$f,Ar=qc.a?qc.a.d:"",hd=void 0,Ao=void 0,Qb=void 0,hd=0,Qb=new M(T);Qb.b=pe)cg=null;else{jj="";ne=new Ql; eg=E(Rl,da,69,T.b.length,0);for(sc=0;scod.a.b.length||1==N(od.a,0).a||yj.length>=N(od.a,0).b.length)we=!1;else{for(pd=0;pd10-qd?0:10-qd,X(K,"score\x3d"+x.d+": linktxt is a num ("+qd+")"));for(var hb=g,ib=ja,bp=K,Or=Cj.length,xc=void 0,vg=void 0,wg=void 0,hb=zm(hb),ib=zm(ib),xc=Or;xc=g)for(f=0;fd?d=!1:(d/=e.height,d=1.3<=d&&3>=d));d&&(d=new Wm,d.e=e.src,d.a=b,d.f=e.width,d.b=e.height,P(this.f,d))}}return Th(this.f,E(Xm,da,27,this.f.b.length, 0))};_.tb=function(){if(null==this.i){var a,b,c;this.i="";a=this.j.getElementsByTagName("*");for(c=0;cc?-1:1,c!=d.a?0!=d.a&&(d=(e=new an,P(a.a,e),e),0!=c&&P(d.b,a.b)):0==c&&(d.b.b=E(nb,da,1,0,3)),P(d.b,b),a.b=b,d.a=c)}function bn(){this.a=new Q}n(125,1,{},bn);_.b=null;t(125);function an(){this.b=new Q}n(82,1,{},an);_.a=0;t(82);function Bm(a){this.b=new cn(a);this.a=new Q;this.d=new Q}n(182,1,{},Bm); _.mb=function(){this.a.U(this.a.b.length-1);this.d.U(this.d.b.length-1)};_.nb=function(a){if(!this.b.a)return!1;P(this.a,a);P(this.d,null);1==this.d.b.length&&(this.c=new dn(a),Sh(this.d,0,this.c));if(en(this.b,a))for(a=0;aa.b.length)return null;c=(z(0,a.b.length),a.b[0]);b=(z(1,a.b.length),a.b[1]);if(d=2==a.b.length)d=c.a,e=b.a,d=4<(d>e?d:e);if(d)return null;d=b.a-c.a;if(0==d)return null;b=~~((b.b-c.b)/d);if(0==b)return null;c=c.b-b*c.a;if(0!=c&&c!=-b)return null;for(d=2;d=f||f>=b.b.length-1)return q;c=(z(f,b.b.length),b.b[f]).a;(z(f-1,b.b.length),b.b[f-1]).a==c-1&&(z(f+1,b.b.length),b.b[f+1]).a==c+1&&(q.b=!0,q.c=(z(f+1,b.b.length),b.b[f+1]).b);return q}if((0==e||1==e)&&1==(z(0,b.b.length),b.b[0]).a&&2==(z(1,b.b.length), b.b[1]).a||2==e&&3==(z(2,b.b.length),b.b[2]).a&&lf((z(1,b.b.length),b.b[1]).b)&&!lf((z(0,b.b.length),b.b[0]).b))return q.b=!0,q;f=b.b.length;if((c==f-1||c==f-2)&&(z(f-2,b.b.length),b.b[f-2]).a+1==(z(f-1,b.b.length),b.b[f-1]).a)return q.b=!0,q;for(e+=1;e=a.b.length)return!1;c=(z(0,a.b.length),a.b[0]);if(1!=c.a&&!c.b.length)return!1;d=!1;for(f=new M(a);f.b=l.length||(l=l[1],m=ho.exec(l),l=-1,m&&1= l?(Ml(a.a,new Pl(l,"")),g=!0):Kl(a.a))}b=g}else Kl(a.a),b=!1;if(d||!b)return!1;break;case 1:if(b=f,F("A",b.tagName)){if(d)return!1;++a.c;(b=Jl(a,b,e))?(Ml(a.a,b.a),b=!0):(Kl(a.a),b=!1);if(!b)return!1;break}default:if(!f.hasChildNodes())break;c=!0;d?f=f.lastChild:f=f.firstChild}return Ll(a,f,c,d,e)} function Jl(a,b,c){var d,e,f,g;if(!mm(b))return null;g=Gk(b.innerText);g=Af(g,"[()\\[\\]{}]");g=ug(g);g=mo(g);if(!(0<=g&&100>=g))return null;d=A(b,"href");d.length?(c.setAttribute("href",d),f=c.href):f="";d=!f.length;e=!1;c=null;if(!d){e="javascript:"===f.substr(0,11);c=Gl(f);if(!c||!e&&!F(c.d.host,a.d.d.host))return null;c.d.hash=""}if(!(a=d||e)){b=getComputedStyle(b,null);b=b.cursor.toUpperCase();Tc();a=(He(),Ie);Nc(b);a=a[":"+b];b=D(w(nb,1),da,1,3,[b]);if(!a)throw new af(Qc("Enum constant undefined: %s", b));a=a==(Tc(),Qd)}return a?new to(g,""):new to(g,Va(c.d).replace(Fl,""))}function El(a){this.a=new bn;this.e=a}n(108,1,{},El);_.b="";_.c=0;_.d=null;var Fl,fo=null,ho=null,go=null;t(108);function to(a,b){this.a=new Pl(a,b)}n(79,1,{},to);t(79); function km(){km=k;rm=RegExp("(next|weiter|continue|\x3e([^\\|]|$)|\u00bb([^\\|]|$))","i");vm=RegExp("(prev|early|old|new|\x3c|\u00ab)","i");wm=/article|body|content|entry|hentry|main|page|pagination|post|text|blog|story/i;um=RegExp("combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|shoutbox|sidebar|sponsor|shopping|tags|tool|widget","i");om=RegExp("print|archive|comment|discuss|e[\\-]?mail|share|reply|all|login|sign|single|as one|article|post|\u7bc7","i"); sm=/pag(e|ing|inat)/i;xm=/p(a|g|ag)?(e|ing|ination)?(=|\/)[0-9]{1,2}$/i;tm=/(first|last)/i;nm=/\/?(#.*)?$/;pm=/\d/;lm=new Yi}function X(a,b){var c;3>Ok||(c="",ch(lm,a)&&(c=eh(lm,a)),!c.length||(c+="; "),fh(lm,a,c+b))}function Hl(a){km();var b,c;c=$doc.implementation.createHTMLDocument();b=c.createElement("base");b.href=a;(c.head||c.getElementsByTagName("head")[0]).appendChild(b);a=c.createElement("a");c.body.appendChild(a);return a} function Il(a,b){km();var c,d;d=a.getElementsByTagName("BASE");if(0==d.length)return b;c=Hl(b);d=A(d[0],"href");c.setAttribute("href",d);return c.href}var om,tm,nm,xm,um,rm,pm,sm,wm,vm,lm;function qm(a,b,c){this.b=a;this.d=0;this.c=b;this.a=c}n(109,1,{},qm);_.b=-1;_.d=0;t(109);function uo(a){var b;null==a.a&&(b=(null==a.c&&(a.c=Vl(a.d)),a.c),b.length?a.a=(U(),lg(b,"\\/")):a.a=E(p,h,2,0,4));return a.a}function xo(a){this.d=a} function Gl(a){var b;try{b=new URL(a)}catch(c){b=null}return b?new xo(b):null}n(69,1,{69:1},xo);_.tS=function(){return Va(this.d)};_.a=null;_.b=null;var fm=_.c=null,Rl=t(69);function Vl(a){a=a.pathname.replace(/;.*$/,"");a=a.replace(/^\//,"");return a.replace(/\/$/,"")}function yo(a){var b,c;if(2>a.b)return!1;c=uo(a.g);if(4!=c[a.b].length)return!1;b=mo(c[a.b-1]);return 0=b&&(a=mo(c[a.b-2]),1970a)?!0:!1} function Eo(a,b){var c,d,e,f;f=b.length;e=f-a.f.length;if(!og(b,a.e))return!1;c=a.c;for(d=hf(a.d,e);cd?(e=(Ze(),$e)[d],!e&&(e=$e[d]=new Xe(d)),d=e):d=new Xe(d),d=/[-_;,]/.test(d);if(d||c+a.f.length==f)return!0}else if(c==a.d&&0<=mo(b.substr(c,e-c)))return!0;return!1} function Yl(a,b,c,d){var e;a=Va(a.d);a:{if(47==a.charCodeAt(c-1)&&bb)throw new af("Value in path component is an invalid number: "+e);d=a.substr(0,c)+"[*!]"+a.substr(d,a.length-d);this.g=Gl(d);if(!this.g)throw new af("Invalid URL: "+ d);this.i=d;this.a=b;this.d=c;this.c=qf(this.i,47,this.d);c=uo(this.g);for(this.b=0;this.be||47!=a.charCodeAt(this.c)?!1:0<=mo(tg(a,this.c+1,d))):a=!1}else a=Eo(this,a);return a}; _.Ab=function(a){var b,c;b=uo(a).length;c=uo(this.g).length;if(b>c)return!1;if(1==b&&1==c){c=uo(a)[0];a=uo(this.g)[0];var d;if(c.length&&a.length)for(d=hf(c.length,a.length),b=0;bd&&g>d&&c.charCodeAt(f)==a.charCodeAt(g);--f,--g,e++);return 2*(e+b)>=c.length}a:{e=uo(a);d=uo(this.g);b=!1;for(c=a=0;ae)throw new af("Query value is an invalid number: "+d);b=(b?"?":"\x26")+c+"\x3d";a=a.d.href.replace(b+d,b+"[*!]");this.i=Gl(a);if(!this.i)throw new af("Invalid URL: "+a);this.j=a;this.a=e;this.c=a.indexOf("[*!]");this.e=qf(this.j, 63,this.c-1);this.b=qf(this.j,38,this.c-1);-1==this.b&&(this.b=this.e);!Ko&&(Ko=/\/$/);this.d=tg(this.j,0,this.b).replace(Ko,"");e=this.j.length;this.g=e-this.c-4;0!=this.g&&(this.f=pg(this.j,e-this.g+1))}n(183,1,{},Sl); _.zb=function(a){var b,c;if(0!=this.g&&!kf(a,this.f))return!1;c=a.length-this.g;if(!og(a,this.d))return!1;if(this.b==c||c==this.b-1&&47==this.j.charCodeAt(c))return!0;b=tg(a,this.b,c).toLowerCase();!No&&(No=/^\/|(.html?)$/i);return No.test(b)?!0:sf(a,this.b,this.j,this.b,this.c-this.b)?0<=mo(tg(a,this.c,c)):!1};_.Ab=function(a){a=(null==a.c&&(a.c=Vl(a.d)),a.c);var b=this.i;null==b.c&&(b.c=Vl(b.d));return F(a,b.c)};_.tS=function(){return this.j};_.a=0;_.b=0;_.c=0;_.e=0;_.f="";_.g=0; var Ko=null,No=null;t(183); function Oo(){Oo=k;Po=new Yi;L(Po,"http://schema.org/ImageObject",(Vo(),Wo));L(Po,"http://schema.org/Article",Xo);L(Po,"http://schema.org/BlogPosting",Xo);L(Po,"http://schema.org/NewsArticle",Xo);L(Po,"http://schema.org/ScholarlyArticle",Xo);L(Po,"http://schema.org/TechArticle",Xo);L(Po,"http://schema.org/Person",Yo);L(Po,"http://schema.org/Organization",Zo);L(Po,"http://schema.org/Corporation",Zo);L(Po,"http://schema.org/EducationalOrganization",Zo);L(Po,"http://schema.org/GovernmentOrganization",Zo); L(Po,"http://schema.org/NGO",Zo);$o=new Yi;L($o,"IMG","SRC");L($o,"AUDIO","SRC");L($o,"EMBED","SRC");L($o,"IFRAME","SRC");L($o,"SOURCE","SRC");L($o,"TRACK","SRC");L($o,"VIDEO","SRC");L($o,"A","HREF");L($o,"LINK","HREF");L($o,"AREA","HREF");L($o,"META","CONTENT");L($o,"TIME","DATETIME");L($o,"OBJECT","DATA");L($o,"DATA","VALUE");L($o,"METER","VALUE")}function ap(a){var b,c,d;b=new Q;for(c=0;cf||0>m||0>l||f+l>Aa||m+l>q)throw new Re;if(0!=(I.f&1)&&0==(I.f&4)||pb==v)0m;)g[l]=a[--f];else for(l=m+l;md?-1:c=g.length)return Op(Yp,"",(Z(),Sp));c=null;for(d=b=0;db&&(b=e.length,c=e);d=c;if(!d||1>=d.length)return Op(Zp,"",(Z(),Sp));if((c=a.caption)&&Np(c)||a.tHead||a.tFoot||Mp(f,Hp))return Op($p, "",(Z(),Up));c=new Q;for(e=new M(f);e.b0.95*b){m=!1;b=e.getElementsByTagName("META"); for(l=0;l=c.b.length)return Op(iq,"",(Z(),Sp));if(Mp(f,Ip))return Op(jq,"",(Z(),Sp));f=(e.offsetHeight||0)|0;return 00.9*f?Op(kq,"",(Z(),Sp)):Op(lq,"",(Z(),Up))}var Lp,Kp,Jp,Hp,Ip; function Qp(){Qp=k;Rp=new mq("INSIDE_EDITABLE_AREA",0);Tp=new mq("ROLE_TABLE",1);Vp=new mq("ROLE_DESCENDANT",2);Wp=new mq("DATATABLE_0",3);$p=new mq("CAPTION_THEAD_TFOOT_COLGROUP_COL_TH",4);aq=new mq("ABBR_HEADERS_SCOPE",5);bq=new mq("ONLY_HAS_ABBR",6);cq=new mq("MORE_95_PERCENT_DOC_WIDTH",7);dq=new mq("SUMMARY",8);Xp=new mq("NESTED_TABLE",9);Yp=new mq("LESS_EQ_1_ROW",10);Zp=new mq("LESS_EQ_1_COL",11);eq=new mq("MORE_EQ_5_COLS",12);fq=new mq("CELLS_HAVE_BORDER",13);gq=new mq("DIFFERENTLY_COLORED_ROWS", 14);hq=new mq("MORE_EQ_20_ROWS",15);iq=new mq("LESS_EQ_10_CELLS",16);jq=new mq("EMBED_OBJECT_APPLET_IFRAME",17);kq=new mq("MORE_90_PERCENT_DOC_HEIGHT",18);lq=new mq("DEFAULT",19);nq=new mq("UNKNOWN",20)}function mq(a,b){C.call(this,a,b)}n(16,9,{3:1,11:1,9:1,16:1},mq);var aq,$p,fq,Wp,lq,gq,jq,Rp,iq,Zp,Yp,kq,cq,hq,eq,Xp,bq,Vp,Tp,dq,nq,oq=u(16,function(){Qp();return D(w(oq,1),ea,16,0,[Rp,Tp,Vp,Wp,$p,aq,bq,cq,dq,Xp,Yp,Zp,eq,fq,gq,hq,iq,jq,kq,lq,nq])}); function Z(){Z=k;Up=new pq("DATA",0);Sp=new pq("LAYOUT",1)}function pq(a,b){C.call(this,a,b)}n(56,9,{3:1,11:1,9:1,56:1},pq);var Up,Sp,qq=u(56,function(){Z();return D(w(qq,1),ea,56,0,[Up,Sp])});function rq(a,b){var c;c=sq(b);a.appendChild(c);return c}function sq(a){var b;b=a.cloneNode(!1);1==a.nodeType&&(a=getComputedStyle(a,null).direction,!a.length&&(a="auto"),b.setAttribute("dir",a));return b}function tq(a,b){var c;c=a.parentNode;c||(c=sq(b),c.appendChild(a));return c} function uq(a){return sl(N(a.j,N(a.i,0).a))}function vq(a,b){return S(a.b,b)}function Xk(a,b){a.g+="\n";a.g+=b.g;a.d+=b.d;a.e+=b.e;a.c=0==a.d?0:a.e/a.d;a.a|=b.a;Ph(a.i,b.i);a.b.G(b.b);a.f=hf(a.f,b.f)}function dl(a,b){if(b==a.a)return!1;a.a=b;return!0} function wq(a){var b;b="["+(N(a.j,N(a.i,0).a).j+"-"+N(a.j,N(a.i,a.i.b.length-1).a).j+";");b+="tl\x3d"+a.f+";";b+="nw\x3d"+a.d+";";b+="ld\x3d"+a.c+";";b=b+"]\t"+((a.a?"\u001b[0;32mCONTENT":"\u001b[0;35mboilerplate")+"\u001b[0m,");b+="\u001b[1;30m"+Xg(new Ck(a.b))+"\u001b[0m";return b+="\n"+a.g}function Wk(a,b){var c,d;this.j=a;this.i=new Q;P(this.i,df(b));c=N(this.j,b);this.b=(d=c.e,c.e=new Zi,d);this.d=c.i;this.e=c.g;this.f=c.n;this.g=c.o;this.c=0==this.d?0:this.e/this.d}n(72,1,{},Wk);_.tS=function(){return wq(this)}; _.a=!1;_.c=0;_.d=0;_.e=0;_.f=0;t(72);function Yk(a){this.a=a}n(81,1,{},Yk);t(81);function xq(){xq=k;yq=new Zi;R(yq,"IMG");R(yq,"PICTURE");R(yq,"FIGURE");R(yq,"SPAN");zq=D(w(p,1),h,2,4,["data-src","data-original","datasrc","data-url"])}function Aq(a){var b;b=$doc.createElement("FIGCAPTION");b.textContent=a.innerText||"";return b} function Bq(a,b){var c,d,e;if(!S(yq,b.tagName))return null;a.b="";c="IMG"==b.tagName?b:Im(b,"IMG");if("FIGURE"===b.tagName){d=Im(b,"PICTURE");!d&&(d=Im(b,"IMG"));if(!d)return null;Cq(a,c);(c=Im(b,"FIGCAPTION"))?(e=c.querySelectorAll("A[HREF]"),c=0b.length)return null;b=A(b[0],"data-tweet-id");return b.length?new Jq(a,"twitter",b,null):null}function Lq(){Gq()}n(133,1,{},Lq);_.Cb=function(a){var b;a&&S(Hq,a.tagName)?(b=null,"BLOCKQUOTE"===a.tagName?b=Iq(a):"IFRAME"===a.tagName&&(b=Kq(a)),b&&2<=Ok&&(W("Twitter embed extracted:"),W(" ID: "+b.b)),a=b):a=null;return a};_.Db=function(){return Hq}; var Hq;t(133);function Mq(){Mq=k;Nq=new Zi;R(Nq,"IFRAME")}function Oq(a){var b,c;if(!a||!S(Nq,a.tagName))return null;c=a.src;if(!Nm(c,"player.vimeo.com"))return null;b=$doc.createElement("a");b.href=c;c=Sc(b,"pathname");b=Sm(pg(Sc(b,"search"),1));a:{var d;d=lg(c,"/");for(c=d.length-1;0<=c&&"video"!==d[c];c--)if(0c&&(c=d.indexOf("\x26"));0>c&&(c=d.length);b=d.substr(0,c);d=Sm(d.substr(c+1,d.length-(c+1)));a:{c=lg(b,"/");for(b=c.length-1;0<=b&&"embed"!==c[b];b--)if(0Ok))if(b){W("\u001b[0;34m\x3c\x3c\x3c\x3c\x3c "+c+" \x3e\x3e\x3e\x3e\x3e");if(!(1>Ok)){b="";for(c=new M(a.a);c.bc.b.length)return!1;d=!1;g=(z(0,c.b.length),c.b[0]);for(f=new rh(c,1);f.b=e?(e=!0,a.a?g.f!=c.f&&(e=!1):S(c.b,"BOILERPLATE_HEADING_FUSED")&&(e=!1),S(g.b,"STRICTLY_NOT_CONTENT")!=S(c.b,"STRICTLY_NOT_CONTENT")&&(e=!1),S(g.b,"de.l3s.boilerpipe/TITLE")!=S(c.b,"de.l3s.boilerpipe/TITLE")&&(e=!1),!g.a&&S(g.b,"de.l3s.boilerpipe/LI")&&!S(c.b,"de.l3s.boilerpipe/LI")&& (e=!1),e?(Xk(g,c),qh(f),d=!0):g=c):g=c):g=c;return d}function Uq(a){this.a=a}n(85,1,{},Uq);_.tS=function(){return Xa(Vq),Vq.n+": postFiltering\x3d"+this.a};_.a=!1;var ol,ll,Vq=t(85);function Wq(){Wq=k;cl=RegExp("[\\?\\!\\.\\-\\:]+","g")}function Xq(a,b,c){var d,e;e=lg(b,c);if(1!=e.length)for(b=0;bd||g.length>e.length))d=f,e=g;return 0==e.length?null:ug(e)} function bl(a){Wq();var b;if(a)for(this.a=new Zi,a=Lh(a,0);a.b!=a.d.c;){b=(y(a.b!=a.d.c),a.c=a.b,a.b=a.b.a,++a.a,a.c.c);var c=void 0;b=zf(b);b=Af(b,"'");b=ug(b).toLowerCase();0!=b.length&&R(this.a,b)&&(c=Yq(b,"[ ]*[\\|\u00bb|-][ ]*"),null!=c&&R(this.a,c),c=Yq(b,"[ ]*[\\|\u00bb|:][ ]*"),null!=c&&R(this.a,c),c=Yq(b,"[ ]*[\\|\u00bb|:\\(\\)][ ]*"),null!=c&&R(this.a,c),c=Yq(b,"[ ]*[\\|\u00bb|:\\(\\)\\-][ ]*"),null!=c&&R(this.a,c),c=Yq(b,"[ ]*[\\|\u00bb|,|:\\(\\)\\-][ ]*"),null!=c&&R(this.a,c),c=Yq(b,"[ ]*[\\|\u00bb|,|:\\(\\)\\-\u00a0][ ]*"), null!=c&&R(this.a,c),Xq(this.a,b,"[ ]+[\\|][ ]+"),Xq(this.a,b,"[ ]+[\\-][ ]+"),R(this.a,Yf(b," - [^\\-]+$")),R(this.a,Yf(b,"^[^\\-]+ - ")))}else this.a=null}n(136,1,{},bl);var cl;t(136);function pl(){pl=k;ql=new Zq(!0)}function Zq(a){this.a=a}n(87,1,{},Zq);_.a=!1;var ql;t(87);function $q(a,b,c){b=N(a.d,b);c=N(a.d,c);return a.c||(b.nodeType!=c.nodeType?0:1!=b.nodeType||b.nodeName===c.nodeName)?b.parentNode==c.parentNode:!1} function hl(a,b){var c,d,e,f,g,l,m,q,v,I;a.g=b.a;if(2>a.g.b.length)return!1;d=a.g;e=$doc.documentElement;l=new Q;for(f=0;fa.e?I==e&&++e:$q(a,v,c)&&(g=!0,dl(N(a.g,c),!0),d[I]=d[e++]);else if(N(a.g,v).c<=a.f&&!N(a.g,v).a&&!vq(N(a.g,v),"STRICTLY_NOT_CONTENT")&&!vq(N(a.g,v),"de.l3s.boilerpipe/TITLE")){for(I=m;Ia.e)I==m&&++m;else if($q(a,v,c)){g=!0;dl(N(a.g,v),!0);l[I]=l[m++]; break}I==q?d[f++]=v:l[q++]=v}return g}function br(a,b,c,d,e){this.b=a;this.a=b;this.c=c;this.f=d;this.e=e}n(138,1,{},br);_.a=!1;_.b=!1;_.c=!1;_.e=0;_.f=0;t(138);function gl(){var a=new cr;a.a=!0;return a}function il(a){return new br(a.b,a.a,a.c,a.e,a.d)}function cr(){this.c=this.a=this.b=!1;this.d=this.e=0}n(84,1,{},cr);_.a=!1;_.b=!1;_.c=!1;_.d=0;_.e=0;t(84);function ml(){ml=k;nl=new dr("de.l3s.boilerpipe/TITLE")}function dr(a){this.a=a}n(86,1,{},dr);var nl;t(86); function el(){el=k;fl=new er(D(w(p,1),h,2,4,["STRICTLY_NOT_CONTENT"]))}function er(a){this.a=a}n(137,1,{},er);var fl;t(137); function fr(a,b){var c,d,e,f,g,l,m;m=mm(b);g=l=!1;m||(a.i&&a.d&&(a.f||(g=b.classList.contains("hidden")),(a.f||g)&&(l=!0)),a.i&&(-1!=A(b,"class").indexOf("continue")&&(l=!0),"false"===A(b,"aria-expanded")&&(l=!0)));var q=m||l,v;2>Ok||(v=getComputedStyle(b,null),W((q?"KEEP ":"SKIP ")+b.tagName+": id\x3d"+b.id+", dsp\x3d"+v.display+", vis\x3d"+v.visibility+", opaq\x3d"+v.opacity));if(!m&&!l)return R(a.e,b),!1;try{if(S(a.b,b.tagName))for(f=new M(a.c);f.bOk||(d=B(b),W("TABLE: "+c+", id\x3d"+b.id+", class\x3d"+A(b,"class")+", parent\x3d["+d.tagName+", id\x3d"+d.id+", class\x3d"+A(d,"class")+"]"));if(c==(Z(),Up))return g=a.a,Vk(g,g.d),P(g.b.a,new Jr(b)),!1;break;case "VIDEO":return g=a.a,c=new Kr(b),Vk(g,g.d),P(g.b.a,c),!1;case "OPTION":case "OBJECT":case "EMBED":case "APPLET":return a.a.c=!0,!1;case "HEAD":case "STYLE":case "SCRIPT":case "LINK":case "NOSCRIPT":case "IFRAME":case "svg":return!1}c= a.a;Qr();f=getComputedStyle(b,null);d=new Rr;e=b.tagName;switch(f.display){case "inline":break;case "inline-block":case "inline-flex":d.a=!0;break;case "block":if("none"!==f["float"]&&"SPAN"===e)break;default:d.b=!0,d.a=!0}if("HTML"!==e&&"BODY"!==e&&"ARTICLE"!==e)switch(l=A(b,"class"),f=b.classList.length,m=A(b,"id"),(Sr.test(l)||Sr.test(m))&&2>=f&&(f=d.d,f[f.length]="STRICTLY_NOT_CONTENT"),e){case "ASIDE":case "NAV":e=d.d;e[e.length]="STRICTLY_NOT_CONTENT";break;case "LI":e=d.d;e[e.length]="de.l3s.boilerpipe/LI"; break;case "H1":e=d.d;e[e.length]="de.l3s.boilerpipe/H1";e=d.d;e[e.length]="de.l3s.boilerpipe/HEADING";break;case "H2":e=d.d;e[e.length]="de.l3s.boilerpipe/H2";e=d.d;e[e.length]="de.l3s.boilerpipe/HEADING";break;case "H3":e=d.d;e[e.length]="de.l3s.boilerpipe/H3";e=d.d;e[e.length]="de.l3s.boilerpipe/HEADING";break;case "H4":case "H5":case "H6":e=d.d;e[e.length]="de.l3s.boilerpipe/HEADING";break;case "A":d.a=!0,b.hasAttribute("href")&&(d.c=!0)}P(c.a.a,d);d.a&&++c.f;d.c&&(e=c.g,e.e=!0,e.j+=" ");c.c|= d.b;c=(Te(),a.f?We:Ue);P(a.g.a,c);a.f|=g;return!0}function Qk(a){var b;this.g=new jk;this.e=new Zi;this.a=a;this.c=new Q;P(this.c,new Fq);P(this.c,new Lq);P(this.c,new Pq);P(this.c,new Tq);this.b=new Zi;for(b=new M(this.c);b.b=b)return 0;c=(a.offsetWidth||0)|0;a=0;b=c/b;1.4500000476837158b?a=1:1.2999999523162842b&&(a=0.4000000059604645);return Wa(this.a*a)};_.Hb=function(){return this.a};_.a=0;t(157);function vl(a){this.b=25;this.a=a}n(158,217,{},vl); _.Fb=function(a){var b;if(!this.a)return 0;a=Lm(this.a).b.length-1-(Lm(Jm(this.a,a)).b.length-1);b=0;4>a?b=1:6>a?b=0.6000000238418579:8>a&&(b=0.20000000298023224);return Wa(this.b*b)};_.Hb=function(){return this.b};_.b=0;t(158);function wl(){this.a=15}n(159,217,{},wl);_.Fb=function(a){var b;a=Lm(a);for(b=new M(a);b.b img, #loadingIndicator > svg { display: block; height: 2.5em; margin: auto; width: 2.5em; } /* Margins for Show Original link. */ .light #closeReaderView { border-top: 1px solid #E0E0E0; color: #4285F4; } .dark #closeReaderView { border-top: 1px solid #555; color: #3adaff; } .sepia #closeReaderView { border-top: 1px solid rgb(147, 125, 102); color: #55F; } video::-webkit-media-controls-fullscreen-button { display: none; } #closeReaderView { /* TODO(mdjones): Remove the "display: none;" style when the Reader Mode bar behaves like the toolbar when scrolling. */ display: none; flex: 0 0 auto; font-family: 'Roboto', sans-serif; font-weight: 700; line-height: 14px; padding: 24px 16px; font-size: 14px; text-align: right; text-decoration: none; text-transform: uppercase; width: 100%; } #content { margin: 24px 16px 24px 16px; } #mainContent { flex: 1 1 auto; margin: 0px auto; width: 100%; } @media screen { #mainContent { max-width: 35em; } } #articleHeader { margin-top: 24px; width: 100%; } #titleHolder { font-size: 1.714rem; line-height: 1.417; margin: 0 16px; } blockquote { border-left: 4px solid #888; padding-left: 1em; } cite { opacity: .8; font-style: italic; } hr { opacity: .5; border-style: solid; height: 1px 0 0 0; width: 75%; } q { opacity: .8; display:block; font-style: italic; font-weight: 600; } embed, img, object, video { max-width: 100%; } /* TODO(sunangel): make images zoomable. */ img { display: block; height: auto; margin: 0.6rem auto 0.4rem auto; } /* TODO(nyquist): set these classes directly in the dom distiller. */ embed+[class*='caption'], figcaption, img+[class*='caption'], object+[class*='caption'], video+[class*='caption'] { opacity: .8; display: table; margin-bottom: 1rem; font-size: 0.857rem; line-height: 1.667; } ol, ul { margin-left: 1.296rem; } code, pre { border: 1px solid; border-radius: 2px; } pre code { border: none; } pre { line-height: 1.642; padding: .5em; white-space: pre-wrap; } body .hidden { display: none; } .clear { clear: both; } /* Iframe sizing. */ .youtubeContainer { height: 0px; /* This is the perecnt height of a standard HD video. */ padding-bottom: 56.25%; position: relative; width: 100%; } .youtubeIframe { height: 100%; left: 0px; position: absolute; top: 0px; width: 100%; } /* 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. */ /* The following are iOS specific rules for rendering on WebKit instead of * Blink. */ #mainContent { -webkit-flex: 1 1 auto; } #closeReaderView { -webkit-flex: 0 0 auto; } #contentWrap { -webkit-flex-flow: column; display: -webkit-flex; } Material design circular activity spinner with CSS3 animation // 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 elems = document.querySelectorAll( 'meta[property="og:type"],meta[name="og:type"]'); for (var i in elems) { if (elems[i].content && elems[i].content.toUpperCase() == 'ARTICLE') { return true; } } var elems = document.querySelectorAll( '*[itemtype="http://schema.org/Article"]'); for (var i in elems) { if (elems[i].itemscope) { return true; } } return false; })() // 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() { function hasOGArticle() { var elems = document.head.querySelectorAll( 'meta[property="og:type"],meta[name="og:type"]'); for (var i in elems) { if (elems[i].content && elems[i].content.toUpperCase() == 'ARTICLE') { return true; } } return false; } var body = document.body; if (!body) { return false; } return JSON.stringify({ 'opengraph': hasOGArticle(), 'url': document.location.href, 'numElements': body.querySelectorAll('*').length, 'numAnchors': body.querySelectorAll('a').length, 'numForms': body.querySelectorAll('form').length, 'innerText': body.innerText, 'textContent': body.textContent, 'innerHTML': body.innerHTML, }); })() d@Er`M@ #@S͡<@?8t@?h?_D?.v?˞b?/@k?@jɩ˿?.+?`?$!? ?NB? ?^Kҹ? H?ÓI?8p@K)Ŀ Pg?eA45ʖ@Q?M2??$5C?@Y+@X? H?`2c?`?-,} ?_U:??s8'j@lHp@ uO?/@B~? ?CC2z ^@mc߷@h)r?O?@κp?][?j@ _@!NM?`?>(?3IϺ?| }?=,?Ʒ9,@06:?j@积f@.$?@t*טp@7d?7@UF@8%; ??h`?S0~@B0Ǹ@7`+??l?d?RB???v$Z`Z@Rah@k#u?|@??' !?@=hH@ S??_ H?A׵??Ɵ9@?12c7??wк䵿j@v5`@#1N?@ $[@P?@d9;@i&4?V@ȥұ@=G??1b?#@'??jg??i9^?d?D?ƀt?`?x]%0?-Ѧ&(%?BB?T?P2)?"wd?? :?@ٙ/?`z@t/)}@7 _Jp?p@?qƭN@J213ą@B?0?GpE?@w `@2腆ӥ??0YA?@`ti@ ?-@k(m@qX޸? ?@T²??MIVTd A@kHUfJ? |@q] W? ?Y[]?@͇;п? ? @@@F_ȿ/@z+pN?C@Z? @@@b ƿgE@FG=Z<@}qU? ?@M¿#@p $?B@C4,㵿rW@":?gE@y7mr@>"2z Q@I$Ĺ?  @XctG@Q{Ho?`@?G4?`*C?AЪs5?9Wr?`ƃ?Ul9C@vk{@y?8.2? ;@& Q@4%l? @!@OC=? @Kut泿a?济Ȗ豿;@?~3?z@s(ǰ  ]@F]n?0b@.@??ngpN@jEhԲ?!@sC@$_ @ebJ?WQ??Rx@V33r(?l@ ]@hD6?`W@o{! ?8}O=? @^]@e.-?v@BĀꩿhs@71FJ?pګ?uS';@+!믿0>`@R7V8?@/LeAU@x9;@h&x?z@h- /@0x)Ы?@D@\ ^˅ N@x ? B@݄諿 ?S6[? ?:X,@>8X?`@?Q v.??Pa-hƭPT?b?0 ?H~K?F%X?06{?el@D@ \?U@/n?eG:ox@:]?l@=C`@!?p8H@Q59@ʮ?@K@eGG@G wa?@G@zHF59@Myi.5?@K@dj;@)c?a?}橿@rH Q@|1@|Rk?PT?<2?0 ?53dZ`@?(#??NS=wPT?./ .?0 ?,jrHH禿{?,l?@,X Mya?uRdbd0@@2;)??@X6??0"? @?q?}NY@&/X? ?#\^z3?%e@@>!ϰگ@A{O}?@Vcி@Z,@N? %@Rii?@@@`qIP@K~5?p@}|#o?`@@LJ㬿H@{ ?p6d@ցzxߺ?ď㓥@(x8?0n@z3{`-g?:\a>?`_V]@\o=q? 1@F\??oWVT @I@^ @?@]~A?`@@St*E@3|?`@@ܹ}:z͠$@ 1?` @j.̢PC?;짿hv@P? !@X??ʈhDf?D@dֳw@4D(?@T\6t@_iI? @(.➿Ь$@k?W@7$`8r@|?ߺ?ۀj #?(??B6"z1g@@@ /&k?`=d@ CsR@zQX? @]20c?;@F\%e@@qQQCE@bN? @V? <@#  2@ 0??# O?ڶ@:) z`-g?~}c?p45?.& |@a5~?O@As? @J@@? `@.?|@}&?@:Q@jetĚ @YE>?y&6@xM@`<Ǘ?1g@.R@D@?O@X:t@e^?n@ pc@W!gԖ?(@!OnXv@|ZNV? R@Ҋ:W@?D?IYT@ҷc$@.˼?%e@@j(^@07:?`=d@/#b ?7zA?4z ?p45?CY|?y꧖? c]?(P`-g?.s?Ŝw?3ulujd mDRU`/qAMaC.y:gW?<_ymYɒI/gelDD3Yޕk~v-J=]f˓&K߀N`Z'a.|RĽ^?~_MqJ,^8uXYƘ@Slђ|}pXd ,A~|Cjoa#\cQ\93U>%ZٶGDe3Oq=ƔUR-D>"*D/gnnr U_uVVYUJ|[C.[Z*9<ƊL2Ą95E>dJBU_ȪWA4USod3u!nRUqԬmҘH{]ͬϵt tT02h#P\u,M(&}d'PP42 ̃%w^JyIsVCJ-RIeNC-$Ѻ[iߋZ7^比D`f\fa5KQ7D&+QNJ5 Y+Xm} ֧/#_ωlEMB<}n3g]@auldVh:zHNFv3ymsnUL}M0M|ΥFӐTf|!`$ ~hs %iX$R*j.a2'ᦚ!KQueCjY;|ՎKDMVnuk%R:̳fDPV)ibr +L׍z 2X))*a#~9'h@Gx4"3Lcy8!XP1l$=[]y r|#>GBX4D\-P.1;#4*)8X3WȺޭ05n6r\ƭNoVB]{{Y7Q#snZIYFo:u um6K(s}bɺ5n Xx#n'YDg#$V|sWMM?^p`Z*.*0HG>R(+/@TU=_ [8܋#ơq 81`o1ΐV!W 2O/S si rJ fgA0h&#?|cnl}>utVɋzbȍ(>c(L4T=Lʮ`k;MQF@v 1 רЖ`s uVVl.9nRUYʇF''HbX_q(( F<[dI_Z2y@- 7"ňbڨgz!#"HFB.o% J,IDœ-HkcvNJo0 P`~F9Rtʥn3d Ve!\F8p>TT+5Ja4*2*nʦ'&h#j'7+i}Z+8 ᰊ4Uf X֞U䞠3I~ҼP)P,@*::cY0VlVlPNq !YU{nH[1JূW/GBzn*窬Dc#;곐Bm|("HN~4Pv ɴ`EgggGIY_Ȕv2wD{(QR{b9+?fJwRVſ6kPBLz'~U)>P[r{=V|gဤ/xu@?p͚CzA 9u㻌&P6PF>ۨ +G/^IۺA(?w{9B~|8&Q&<˞}LrFt ~ت g#,GQqeT՘뫼u;(8z/y Fj;W:-*~onv۟L]8OJ׿wOO^dE;ksF+ƹԂHNU]I)F[VOCr$bIܘ1 ^A~wH̲.׫u!FX|Z:6܈erI"Ј\ߩ8<8:Rkm=k[Q8/bG,bpM1:MiV/Ũ-12 vCmT1b*IPr+g&T/HJPX ||I_USp:f@ u^U%CetEEUF$YD9\KG%REPe.W'wz% B0,I"ELhHT " 0eeHeZDeF朞9)V&G" ,m&XqrQ 88UZxZ6D KB%qSIQWAEQY#i5 pHT{5zmaeBۓPw53=iCk`Y`PMo Nnɪ@XROIgL$2l!½9 dGN"UQhB2j- (r;;3#N9lI lwu6Wζnc~s;Xf!#8f_]m6cɽ(A S\CE`W@bFH_^pg&XT)HSf4-- D28=|Pw# F͵d @LQ4YDŽ17dmD-찒XNA"h軣>{yzLhs vj,64  ouZ>C+CuP̅,70| ȚD5?snkHL `ƒ瀽SО}ϯp9/>12 vŻ=OHG=say8*֨U(to 2iXʹ!ϧ)zPzlCB!ͳ;mB'h@ߠsMZ)L߱TP,3zXsh(Qn!?ڤU&`we 4b#cvq 1-\a~ XmZhZa/Z:/lSbᣔށk-W5R4Ph x;nP,Td%iUT546 0EU8Oj0H`WΎ< Y-jqH+NPmJb7 3!VOKBSͫogƈy _F(F0[Q"iGcn@hw-lν%D[f܃?-7/E%J{[2YZX۳'l֌>Q\k`A݃"ztIEM#S,I{W,6.Q 4;+)ңui!1[bśn-Xe\V*{ijN:aI2[i##E~`<&=ԡXhBpTUFS,dEAvϣYk'hKlCmz~O|,1 mPxб]{!ghPNo}GHh?]xS5JlO<ĬVAQG s7ǵ4@؆[,G waPVz$A ]8h jT0]d w>K xҀRP0#0p=ă^U1Wv^OC@ƍDȒ~ -PVE*?c@C0$'Y|-[\E>r'Zd`ʺ23]W)\T%gÌPZ  hDp*[3g+y{҃qn"U-f' .2=wSIXy`?PAQF\cPwDxgds(85N癡_%Ts|: m;Đߊr+9hǞ{UxUG`EVK`c^IJ%Z$G!]UݰIB)cM˶M0 Abv KGx ܞ?.zjԕj#ecW;\x j:$e\i2ɺ -Tu<7./3M@ At':iKqk81,ٽg=FzwKa"/=~BO8͔Ѳ&XWLzvr43gҏN(rhHW|mM`"{[Dp$],8Y!k o`+.%Gr=|D%}F1$}Og},u$.ϵ"2j\[i=iC`0- Gʍ>F'le6EE 3"Vζ)8w k3I۸xJ#>vՌC@m݌݌VsA`9K+E`#Nط;˿0ɃƽUQ97DLu/\a$@jj$&nj[>qe뼮V7gzAQgPD)?ڪi?EgIc( ?@6Q<X_S6ϧغ}I Mj2CanZ>3u8+ H ]IN0$MŸ!Vڕ_BtRB;}[~xkiJUEP2q2!21 xδA{v/s8--&MF!kXFN 49 8>xIѨZs4 7&q] f$je[uC0t;zG͕6pT 7(v;5K4 FS`q!K29Y #t;U] %N'/O`, ܅ɻP:m7~o'Z0ɲ{nR1!d]c7K 1e!^muCq @`k!:|~=>͗_ƟБ܇*Gx;biMކ~ySHjkURSzD, XjcEjb] /f, Ŗ(=4 q)3kYY0/z#5YzfVb ^ fsv:s k+vj"ŬVJ*#4 &)1uէ y`FMdr>a&aqQ+e4L[ ˂tL@י3[ HɐLa-4r{)!p_9'z DOJp*5 {c)RUaV!RV%TP#·*X&Xc0X@"[|mP5t<ע\I*eܘ*/f:ܺEU7^@Jn6I,rQ\?՗2MC^msȗwQbr[փC'9(&+-bƤS(G߮LM/Dwڑ.|ROO$<" x(>B%T,fLӐ;d(_M6?]o6ݿJ7^eNH[,ɞ<m1)zEԗ-;)V&}9?i^lZ~~/pJ&_rKsc#2`$ FZi2𷕐/ʂKKDZ-oAwWc붙LR#KXhKKx/f6|w h0Mȅr4\ƫvXdvxRNzt [gT솓^A]neD%\ %߾-,TEb~l! Ҹ-8#4 V:w> GmC#x^2+:q"+ U"@QmIԑp3FɗݳWﲭ M0K2l-9È̢Ϭ|l/%*!2@A6-)숔; yyU67apI<tC@H#\\GK-jMg h0z,8pGWm {٩[S͐Q}Cd X%` &>ʘ)d[t&i,(4ShduB=;;c^jɦS^V%e2a4W80n *#xF&E1K޶Ƣ(0jVRN?t3tuL#\akj4$[NѪ3ٵ%2gX6)Lb V߱"UcP)('W 3ag 7UOq.\7DJ|3u m9xxI(P*Tx ڶ5X"$"CPҤEsjcVwT6v<M]-iH|I%5z7vrr flx?ϯ 2.wiH$KV']utIc~0b 9!j-w]"c-XB4;,G>ƌW9>רSjcg3CDU|+r2Pwݩ>~Uknf|$:C=_a}^ZǴ4՗O!zorkYԈGF,'Yr۸Ӊ撋"kq|gtq]>y@r%L<vdG+t4EN.I*X?KebV%Bf|27_R*0H242`2N/ hId(Reѯ: χ`2͓9:#ЄxWJeM?sq @U:C4d g['ZGM3VFi%ӓr<3:C8xUIe2NFM54ɸ-$2Es@%0x ][yH-D \:xO42]@ 5?9#8NGߨܩ݅KlX̌UFv*+cpR˜F2}.a-[ZbM%y&Ay2,Yr1x!dGk"sFWgO6X yݵOKҡaC{XeX+|MUn]2%Hή0 7ݭ11\n: bd9YaLrN5MK 3V_H!uPv )yT$!eSa憃:\ڌ%`lhRT%8V_ulNZz|}[}<9.z JpYNJІ^\| yLi+||,+…׾*$9;N\\x2 y׌(-G 2?P wzw\]=bKp`wɩA䏛bwS*xAM*  7z8ϝ a=o=<8e:w9ֻ@&4T"7 ?[Ώ'ǤHo`U&ÛJZR91es)ej@.(윬5m2Yv4@6.'HVL5R5W*meBCkV.&sH.[%c`-V:kIٲډc/ptQ#ڊZ;;@KeªƠ-=euE4*>HF mt}1p>=Y0l]qpftBEO"{W7d=K6Ռn‡32w ٽV͜&OL='-:!WFV\LcXRgj5ķ}4l#;ht>s1uRBtkF.Eik<QH1?[ u  j)M(W<*JR]e]\zzoSOL製C'5x:ރRiY|t#eUY R&"TDT,@ѩ =LցjrRx ZWs80hҭ2rm @=c 1SQ%~68$;s?kR4o"Q4)iyOMkV"%fLt˵ URd /Hݭ+zSRT\(|}͛)׊&!u>sRJ?"6jbSХdkG\m OO<7wT lkI)<ӕ xC81-=gs(.Ljc:[s{DygЯd+A NA,Y}k6egBg[)J( ;ENEPOI&UHٮjk#3:`15WZ(\IQfWWG0DpR5J?6_1ٙژOb&̍\ܤt;g=IX5S3hc9VM{*f >PbA;5~h,ZoۼQr+O}JamR4 (Zm.Q]A))| Eߑ9>fTr2,Jf+ޭZjTft7Yhbɴ(ވ4R0`f%KVJ'%* Tsq|R]&X&2%?> ^)-.es ;<0~|yd!EQb[ w2 ||d2O,tſn!1yQ\w} F& `o|UbcA"8 (K@ȏHrebG0t7E@ZTlFi(ka ( -ƑJQ-Wo ܎c`M1_D癸_$8^1% QWY|[Ca tVb 2Bu0@Bg$crd_gNx)بu૟U\"55CR=[u"?d|J4aU~6)<$^kAq$KNd^YԤ䯝ґ噦{rpݜD &N ʇUe!rb HxileQ l~aeiLm x[ 2Ie?mjʄ}8$ duT0u$aQ ?;PNZ"=⮤sYyF}"µ p# H^J46-: fFſ{>:ƺc>'cp-ƫV9"x5ِmyz,4-g:$nid/'q Ҋw\)_6k TK!ǠɜVKcD}빎Rǭ疷/x^{g<'gqjݸ7Z1LoV$ XCK?Y>'e_cdo@]@ H[4'Z݇ྯ=m.T{|T3f)5[⸧g33ڣg}z(C0@}u#x jg?3 ZTU|Ad#9קԳJX NGP+1><"Zp>)KGh8SwO)XМ@ tuBdt:۩`gW]鉉0Aj wA:߽t2dqUU"Y?\퍎"q-͵nm>>MdTMϩ9pds3iՁۨi:WqoM\p uFI_xnϭ[R8^4hpo >{:}6MH륭"+o )`_"G}3怾r[*TtFUԿxs<1ogf \v~tc+hb/' $i18n{title}

XS6~_sۻڐGK:}(&(G9Ndb. ICۗ$[N9Gi6|kb GL<\ǩT r(BHP 5k$sETH2 w `d0!|='$1$ 3LC gGK\@i:8~% \W_@~AST*7CG7pZ@p޻$=Ti"*T/-bX°F}k)?Rp 4Z5dHMB@MA6}1':)qk(4O+3wd\eNZ@ѭS9'`[:zhVOg4bG=N`ܴQ*R"r4xno5Hsw{ΡT$֣uWRʣj* /k^_F;_\/nU3 Ë՚c$NzjӞQ?=iބ}4F[aEN&<ÉfЁ ;Uv͗v[0]<[骰.ňRWBm7x:NǀuT̛9QVZQq|MM{_Gž|{yx?^03»j-7ѩdl/0PGsVPRޮLk"oۦ { ~`o(䒶й!Lcӄ=,J=sod-~nT W.fTn0 +` vvHCd@l+ua(9!Qn!>Z'6_,Q|zfi޾~dL[)mBspN4%&ao4[RL0!V o7D-1"xv9B.b ,"# knJ vliTzLDߋ+iJ*͝4Qn!V$Y1ELޤdž2 HD0==c[A-Q,JPf .VnF6'0iN !|kB6t #|j͒XikYhO!BcLJNpIk=6"6dY[ϑvdk?puk٠m)wm=!)UP |_)%pc.G)nI0NtQ8 4WNmK̹[12ߟ ssDqSBPeII~(J\r)SmMC!mT_\rrb8*8gQ- UI'QV/?lTQ8<^EteuRn0 + ֠v'i1Ԧ= vXZeѐٰ,7E6A${|t1 {oZPK6SgCBPW@-6 ^"HR1liޡfno{`Di48()l.i] L)|1]+H span.lazy-image-placeholder, \ .mwe-math-element > span.lazy-image-placeholder"); noscripts = document.querySelectorAll( ".image > noscript, .mwe-math-element > noscript"); // Next we delete all the placeholders, then move the img elements // out of the noscripts, deleting the noscript element in the // process. for (i = 0; i < placeholders.length; ++i) { placeholders.item(i).remove(); innerText = noscripts.item(i).innerText; noscripts.item(i).outerHTML = innerText; } } var map_renovations = { "wikipedia" : renovation_wikipedia, }; function run_renovations(flist) { for (var func_name of flist) { map_renovations[func_name](); } } Un1WL lS 4=:16{JEB fo BK7,aVmU[u K,:D…sD$k4 +!pm (4Q#`o=ǜujBZ1ha\xKj6|} py.rIGb=4(DϦ,wT4lz؂V!D/Wz>t CO3e/;<|abJgt΍{P=#.݉&noVQ-+ĞS{Oog?qbM4Errgy[}(xz,:Tr8E~csW ?vm@jp1T)G" N0VN#)S-ղr B%&)(Vy CM'@+&r86O k¾Lƾcw̃imt~r$Ʈqϊ2SFF:a P1/q40nY;LJ,샤r'2>AL+ tҀh=G/^r"LNYBVOْIҭvۗon~Fuj0 } IMءK{(Qh*-&^z[U5o: GPkO'OɇVEc@ߣ*U_Z`m^"HRGPA3zxHtZ0HqSK)0.vzy@k,EdCR^M^=u)NQ[.z! ֦wx̳T/ѱ7- L ä(R Wi)gtƪ\/G8vy}V\֍
/* Copyright 2017 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 { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; font-family: "Roboto", sans-serif; display: flex; } header { min-height: 50px; font-size: 24px; background-color: #069BDE; display: flex; padding: 0 20px; color: #f4f4f4; text-align: center; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.12); } .hidden { display: none; } /** CSS for controls panel */ #controls { width: 50%; display: flex; flex-direction: column; overflow-y: hidden; } .logo { font-size: 30px; margin-right: 12px; } #controls-panel { padding: 40px; overflow-y: auto; } .control { padding: 20px; margin-bottom: 60px; box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.2), 0 3px 10px 0 rgba(0, 0, 0, 0.2), 4px 4px 6px 0 rgba(0, 0, 0, 0.5) } .control-title { font-size: 18px; font-weight: bold; margin-bottom: 5px; } .control button { border: 1px solid rgba(0,0,0,0.12); color: #fff; background-color: #7496c8; border: none; cursor: pointer; font-size: 14px; height: 24px; min-width: 80px; } .control button:hover { background-color: #446392; } .control button:active { background-color: #37496d; } /** CSS for logs panel */ #logs-panel { width: 50%; display: flex; flex-direction: column; border-left: 1px solid rgba(0,0,0,0.12); } #logs-panel > header { flex-direction: row-reverse; } #save-logs-button, #clear-logs-button { background-color: rgba(0,0,0,0); border: none; color: #fff; cursor: pointer; font-size: 20px; height: 100%; padding: 0 25px; } #save-logs-button:hover, #clear-logs-button:hover { cursor: pointer; color: #535553; } #save-logs-button:focus, #clear-logs-button:focus { outline: 0; } #logs-list { display: flex; flex-direction: column; height: 100%; overflow-y: scroll; overflow-x: hidden; } .log-item { border-bottom: 1px solid rgba(0, 0, 0, 0.12); font-family: monospace; font-size: 12px; padding: 12px 26px; } .log-item[severity="1"] { background-color: #fffcef; color: #312200; } .log-item[severity="2"] { background-color: #fff1f1; color: #ef0000; } .item-metadata { color: #888888; font-size: 10px; display: flex; margin-bottom: 4px; } .item-metadata > .flex { flex: 1; } .item-text { margin: 0; display: inline-block; width: 100%; text-overflow: clip; overflow-x: auto; white-space: pre-wrap; } /* Copyright 2017 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. */ Logs = { controller_: null, /** * Initializes the logs UI. */ init: function() { Logs.controller_ = new LogsListController(); var clearLogsButton = document.getElementById('clear-logs-button'); clearLogsButton.onclick = function() { WebUI.clearLogs(); }; var saveLogsButton = document.getElementById('save-logs-button'); saveLogsButton.onclick = () => { this.saveLogs(); }; WebUI.getLogMessages(); }, saveLogs: function() { var blob = new Blob( [document.getElementById('logs-list').innerText], {type: 'text/plain;charset=utf-8'}); var url = URL.createObjectURL(blob); var anchorEl = document.createElement('a'); anchorEl.href = url; anchorEl.download = 'proximity_auth_logs_' + new Date().toJSON() + '.txt'; document.body.appendChild(anchorEl); anchorEl.click(); window.setTimeout(function() { document.body.removeChild(anchorEl); window.URL.revokeObjectURL(url); }, 0); }, }; /** * Interface with the native WebUI component for LogBuffer events. The functions * contained in this object will be invoked by the browser for each operation * performed on the native LogBuffer. */ LogBufferInterface = { /** * Called when a new log message is added. */ onLogMessageAdded: function(log) { if (Logs.controller_) { Logs.controller_.add(log); } }, /** * Called when the log buffer is cleared. */ onLogBufferCleared: function() { if (Logs.controller_) { Logs.controller_.clear(); } }, /** * Called in response to chrome.send('getLogMessages') with the log messages * currently in the buffer. */ onGotLogMessages: function(messages) { if (Logs.controller_) { Logs.controller_.set(messages); } }, }; /** * Controller for the logs list element, updating it based on user input and * logs received from native code. */ class LogsListController { constructor() { this.logsList_ = document.getElementById('logs-list'); this.itemTemplate_ = document.getElementById('item-template'); this.shouldSnapToBottom_ = true; this.logsList_.onscroll = this.onScroll_.bind(this); } /** * Listener for scroll event of the logs list element, used for snap to bottom * logic. */ onScroll_() { const list = this.logsList_; this.shouldSnapToBottom_ = list.scrollTop + list.offsetHeight == list.scrollHeight; } /** * Clears all log items from the logs list. */ clear() { var items = this.logsList_.querySelectorAll('.log-item'); for (var i = 0; i < items.length; ++i) { items[i].remove(); } this.shouldSnapToBottom_ = true; } /** * Adds a log to the logs list. */ add(log) { var directories = log.file.split('/'); var source = directories[directories.length - 1] + ':' + log.line; var t = this.itemTemplate_.content; t.querySelector('.log-item').attributes.severity.value = log.severity; t.querySelector('.item-time').textContent = log.time; t.querySelector('.item-source').textContent = source; t.querySelector('.item-text').textContent = log.text; var newLogItem = document.importNode(this.itemTemplate_.content, true); this.logsList_.appendChild(newLogItem); if (this.shouldSnapToBottom_) { this.logsList_.scrollTop = this.logsList_.scrollHeight; } } /** * Initializes the log list from an array of logs. */ set(logs) { this.clear(); for (var i = 0; i < logs.length; ++i) { this.add(logs[i]); } } } /* Copyright 2017 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. */ /** * JavaScript hooks into the native WebUI handler. */ WebUI = { getLogMessages: function() { chrome.send('getLogMessages'); }, clearLogs: function() { chrome.send('clearLogBuffer'); }, findEligibleUnlockDevices: function() { chrome.send('findEligibleUnlockDevices'); }, forceDeviceSync: function() { chrome.send('forceDeviceSync'); }, forceEnrollment: function() { chrome.send('forceEnrollment'); }, generateChallenge: function() { chrome.send('generateChallenge'); }, getAssertion: function() { chrome.send('getAssertion'); }, getLocalState: function() { chrome.send('getLocalState'); }, onWebContentsInitialized: function() { chrome.send('onWebContentsInitialized'); }, toggleConnection: function(publicKey) { chrome.send('toggleConnection', [publicKey]); }, toggleUnlockKey: function(publicKey, makeUnlockKey) { chrome.send('toggleUnlockKey', [publicKey, makeUnlockKey]); }, };
ProximityAuth
CRYPTAUTH
DEVICE ID (truncated) ----
ENROLLMENT
Last Success
Next Refresh
DEVICE SYNC
Last Success
Next Refresh
REMOTE DEVICES
ELIGIBLE UNLOCK KEYS
ELIGIBLE
INELIGIBLE
/* Copyright 2017 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. */ table { margin-left: 10px; } td { padding-right: 30px; } td[state='in-progress'] { color: orange; } td[state='failure'] { color: red; } tr.subrow { font-size: 14px; } tr.subrow td:first-of-type { text-align: right; } .flex { flex: 1; } #local-device-id { font-family: monospace; } .remote-device { display: flex; flex-direction: column; width: 100%; } .remote-device:hover { background-color: #eee; } .remote-device .status-name-container, .remote-device .button-container { align-items: center; display: flex; } .device-connection-status { font-size: 24px; margin-right: 10px; } .device-connection-status[state='connecting'] { color: orange; } .device-connection-status[state='connected'] { color: green; } .remote-device table { font-size: 12px; margin-left: 22px; } .remote-device td:last-of-type { font-family: monospace; } .remote-device .button-container { margin: 2px 22px; } .remote-device button { margin: 0px 2px; } #eligible-devices-list[device-count='0'], #ineligible-devices-list[device-count='0'] { display: none; } /* Copyright 2017 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. */ ProximityAuth = { cryptauthController_: null, remoteDevicesController_: null, findEligibleDevicesController_: null, /** * Initializes all UI elements of the ProximityAuth debug page. */ init: function() { ProximityAuth.cryptauthController_ = new CryptAuthController(); ProximityAuth.remoteDevicesController_ = new DeviceListController( document.getElementById('remote-devices-control'), true, false); ProximityAuth.eligibleDevicesController_ = new EligibleDevicesController(); WebUI.getLocalState(); } }; /** * Controller for the CryptAuth controls section. */ class CryptAuthController { constructor() { this.elements_ = { localDeviceId: document.getElementById('local-device-id'), gcmRegistration: document.getElementById('gcm-registration'), currentEid: document.getElementById('current-eid'), enrollmentTitle: document.getElementById('enrollment-title'), lastEnrollment: document.getElementById('last-enrollment'), nextEnrollment: document.getElementById('next-enrollment'), enrollmentButton: document.getElementById('force-enrollment'), deviceSyncTitle: document.getElementById('device-sync-title'), lastDeviceSync: document.getElementById('last-device-sync'), nextDeviceSync: document.getElementById('next-device-sync'), deviceSyncButton: document.getElementById('force-device-sync'), }; this.elements_.enrollmentButton.onclick = this.forceEnrollment_.bind(this); this.elements_.deviceSyncButton.onclick = this.forceDeviceSync_.bind(this); } /** * Sets the local device's ID. Note that this value is truncated since the * full value is very long and does not cleanly fit on the screen. */ setLocalDeviceId(deviceIdTruncated) { this.elements_.localDeviceId.textContent = deviceIdTruncated; } /** * Update the enrollment state in the UI. */ updateEnrollmentState(state) { this.elements_.lastEnrollment.textContent = this.getLastSyncTimeString_(state, 'Never enrolled'); this.elements_.nextEnrollment.textContent = this.getNextRefreshString_(state); if (state['recoveringFromFailure']) { this.elements_.enrollmentTitle.setAttribute('state', 'failure'); } else if (state['operationInProgress']) { this.elements_.enrollmentTitle.setAttribute('state', 'in-progress'); } else { this.elements_.enrollmentTitle.setAttribute('state', 'synced'); } } /** * Updates the device sync state in the UI. */ updateDeviceSyncState(state) { this.elements_.lastDeviceSync.textContent = this.getLastSyncTimeString_(state, 'Never synced'); this.elements_.nextDeviceSync.textContent = this.getNextRefreshString_(state); if (state['recoveringFromFailure']) { this.elements_.deviceSyncTitle.setAttribute('state', 'failure'); } else if (state['operationInProgress']) { this.elements_.deviceSyncTitle.setAttribute('state', 'in-progress'); } else { this.elements_.deviceSyncTitle.setAttribute('state', 'synced'); } } /** * Returns the formatted string of the time of the last sync to be displayed. */ getLastSyncTimeString_(syncState, neverSyncedString) { if (syncState.lastSuccessTime == 0) return neverSyncedString; var date = new Date(syncState.lastSuccessTime); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); } /** * Returns the formatted string of the next time to refresh to be displayed. */ getNextRefreshString_(syncState) { var deltaMillis = syncState.nextRefreshTime; if (deltaMillis == null) return 'unknown'; if (deltaMillis == 0) return 'sync in progress...'; var seconds = deltaMillis / 1000; if (seconds < 60) return Math.round(seconds) + ' seconds to refresh'; var minutes = seconds / 60; if (minutes < 60) return Math.round(minutes) + ' minutes to refresh'; var hours = minutes / 60; if (hours < 24) return Math.round(hours) + ' hours to refresh'; var days = hours / 24; return Math.round(days) + ' days to refresh'; } /** * Forces a CryptAuth enrollment on button click. */ forceEnrollment_() { WebUI.forceEnrollment(); } /** * Forces a device sync on button click. */ forceDeviceSync_() { WebUI.forceDeviceSync(); } }; /** * Controller for a list of remote devices. These lists are displayed in a * number of locations on the debug page. */ class DeviceListController { constructor(rootElement, showScanButton, showToggleUnlockKeyButton) { this.rootElement_ = rootElement; this.showScanButton_ = showScanButton; this.showToggleUnlockKeyButton_ = showToggleUnlockKeyButton; this.remoteDeviceTemplate_ = document.getElementById('remote-device-template'); } /** * Updates the UI with the given remote devices. */ updateRemoteDevices(remoteDevices) { var existingItems = this.rootElement_.querySelectorAll('.remote-device'); for (var i = 0; i < existingItems.length; ++i) { existingItems[i].remove(); } for (var i = 0; i < remoteDevices.length; ++i) { this.rootElement_.appendChild( this.createRemoteDeviceItem_(remoteDevices[i])); } this.rootElement_.setAttribute('device-count', remoteDevices.length); } /** * Creates a DOM element for a given remote device. */ createRemoteDeviceItem_(remoteDevice) { var isUnlockKey = !!remoteDevice['unlockKey']; var hasMobileHotspot = !!remoteDevice['hasMobileHotspot']; var isArcPlusPlusEnrollment = !!remoteDevice['isArcPlusPlusEnrollment']; var isPixelPhone = !!remoteDevice['isPixelPhone']; var t = this.remoteDeviceTemplate_.content; t.querySelector('.device-connection-status').setAttribute( 'state', remoteDevice['connectionStatus']); t.querySelector('.device-name').textContent = remoteDevice['friendlyDeviceName']; t.querySelector('.device-id').textContent = remoteDevice['publicKeyTruncated']; t.querySelector('.is-unlock-key').textContent = isUnlockKey; t.querySelector('.supports-mobile-hotspot').textContent = hasMobileHotspot; t.querySelector('.is-arc-plus-plus-enrollment').textContent = isArcPlusPlusEnrollment; t.querySelector('.is-pixel-phone').textContent = isPixelPhone; if (!!remoteDevice['bluetoothAddress']) { t.querySelector('.bluetooth-address-row').classList.remove('hidden'); t.querySelector('.bluetooth-address').textContent = remoteDevice['bluetoothAddress']; } var scanButton = t.querySelector('.device-scan'); scanButton.classList.toggle( 'hidden', !this.showScanButton_ || !isUnlockKey); scanButton.textContent = remoteDevice['connectionStatus'] == 'disconnected' ? 'EasyUnlock Scan' : 'EasyUnlock Disconnect'; t.querySelector('.device-toggle-key').classList.toggle( 'hidden', !this.showToggleUnlockKeyButton_ || !isUnlockKey); var element = document.importNode(this.remoteDeviceTemplate_.content, true); // Initialize buttons on new element. element.querySelector('.device-scan').onclick = this.scanForDevice_.bind(this, remoteDevice['publicKey']); element.querySelector('.device-toggle-key').onclick = this.toggleUnlockKey_.bind( this, remoteDevice['publicKey'], !remoteDevice['unlockKey']); return element; } /** * Button handler to start scanning and connecting to a device. */ scanForDevice_(publicKey) { WebUI.toggleConnection(publicKey); } /** * Button handler to toggle a device as an unlock key. */ toggleUnlockKey_(publicKey, makeUnlockKey) { console.log(publicKey); WebUI.toggleUnlockKey(publicKey, makeUnlockKey); } } /** * Controller for the 'Eligible Unlock Keys' controls. */ class EligibleDevicesController { constructor() { this.eligibleDeviceList_ = new DeviceListController( document.getElementById('eligible-devices-list'), false, true); this.ineligibleDeviceList_ = new DeviceListController( document.getElementById('ineligible-devices-list'), false, false); this.button_ = document.getElementById('find-eligible-devices'); this.button_.onclick = this.findEligibleUnlockDevices_.bind(this); } /** * Updates the UI with the fetched eligible and ineligible devices. */ updateEligibleDevices(eligibleDevices, ineligibleDevices) { this.eligibleDeviceList_.updateRemoteDevices(eligibleDevices); this.ineligibleDeviceList_.updateRemoteDevices(ineligibleDevices); } /** * Button handler to fetch eligible unlock devices. */ findEligibleUnlockDevices_() { WebUI.findEligibleUnlockDevices(); } } /** * Interface for the native WebUI to call into our JS. */ LocalStateInterface = { onGotLocalState: function( localDeviceId, enrollmentState, deviceSyncState, remoteDevices) { LocalStateInterface.setLocalDeviceId(localDeviceId); LocalStateInterface.onEnrollmentStateChanged(enrollmentState); LocalStateInterface.onDeviceSyncStateChanged(deviceSyncState); LocalStateInterface.onRemoteDevicesChanged(remoteDevices); }, setLocalDeviceId: function(localDeviceId) { ProximityAuth.cryptauthController_.setLocalDeviceId(localDeviceId); }, onEnrollmentStateChanged: function(enrollmentState) { ProximityAuth.cryptauthController_.updateEnrollmentState(enrollmentState); }, onDeviceSyncStateChanged: function(deviceSyncState) { ProximityAuth.cryptauthController_.updateDeviceSyncState(deviceSyncState); }, onRemoteDevicesChanged: function(remoteDevices) { ProximityAuth.remoteDevicesController_.updateRemoteDevices(remoteDevices); } }; /** * Interface for the native WebUI to call into our JS. */ CryptAuthInterface = { onGotEligibleDevices: function(eligibleDevices, ineligibleDevices) { ProximityAuth.eligibleDevicesController_.updateEligibleDevices( eligibleDevices, ineligibleDevices); } }; document.addEventListener('DOMContentLoaded', function() { WebUI.onWebContentsInitialized(); Logs.init(); ProximityAuth.init(); });
POLLUX
SERVER
MASTER_KEY:
GET ASSERTION
CHALLENGE:
EID:
SESSION_KEY:
AUTHENTICATOR STATE
NOT STARTED
/* Copyright 2017 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. */ .control-row { display: flex; align-items: center; margin-bottom: 5px; padding-left: 12px; } .control-row-text { font-size: 14px; width: 100px; } .textinput { min-width: 200px; font-family: monospace; margin: 0 5px; font-size: 16px; } /* Copyright 2017 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. */ Pollux = { controller_: null, /** * Initializes the debug UI. */ init: function() { Pollux.controller_ = new PolluxController(); } }; /** * Interface with the native WebUI component for Pollux events. */ PolluxInterface = { /** * Called when a new challenge is created. */ onChallengeCreated: function(challenge, eid, sessionKey) { if (Pollux.controller_) { Pollux.controller_.add(log); } }, }; class PolluxController { constructor() { this.masterKeyInput_ = document.getElementById('master-key-input'); this.challengeButton_ = document.getElementById('challenge-button'); this.challengeInput_ = document.getElementById('challenge-input'); this.eidInput_ = document.getElementById('eid-input'); this.sessionKeyInput_ = document.getElementById('session-key-input'); this.assertionButton = document.getElementById('assertion-button'); this.authStateElement_ = document.getElementById('authenticator-state'); this.challengeButton_.onclick = this.createNewChallenge_.bind(this); this.assertionButton_.onclick = this.startAssertion_.bind(this); } } document.addEventListener('DOMContentLoaded', function() { WebUI.onWebContentsInitialized(); Logs.init(); }); Oo0&ZS9[X"Ǟ.my&3v{7z}Iee/{ %U@]y^,[CYu ]\x ~] =x@ @:0ִ v@;)3`LJ{j tjPgH`ϲ(B3[+9/Vѯ|T!Cci !a?=\]<,qw™u1A^&:fc5-. 2oxfvvvϦX; W ^a8nLʅXD"u)"uHJGD7:I}P$!v ]&}CI'(U| 24yG%&-:,pF[b3X/KI,OR"t9+\p܇ Wj>Zw1?nܭ 7 ^rVQo6~ׯ,۱\e@H lE1"ƐI͆Eْ9Ͱ#udsy2b;~+GQGͣ{pڤ~hUa5x4N69j@͢i }?G.(%zg\l/V=U)}x~} duq MNs.7DǾ Pw:M jtkS8OlmpjlCcYu6'&X%d-ujzX| :%v>J^-1{C;+D/=j\aTspOszVX~iЪ_A2a <湐 Interstitials

Choose an interstitial

SSL

SafeBrowsing

Loud

Quiet (WebView)

Captive Portal

Supervised Users

$i18n{tabTitle}

$i18n{heading}

$i18nRaw{primaryParagraph}

$i18n{tabTitle}

$i18n{heading} $i18n{openDetails}

X_s7Sפ3>3qlu'Cq0p2t$ޕtGrq1ݟỲ$&Fie_ gp 2٤<9}`~^ 8J9 r3@lJӀ3L@ NLE U :RD3))]<`a+p#ųakxK!n|\eTFE~RX!ZŵW!jߋ<\)|BR64~_o$šA Y^ v4))dr]rOj)2[1_=V?,^8bg#I<^ư63|jRGE dXPՑc(q>lcY鴽_qwRe ݟ0H9 "- ٧Zap%xIpߖ!N_Ujƫłj-.p1 -CQ!Ȑug۸Βv%0LU4XϝST!R\4q`:ۿ?nHMdz]vaAW3`{`ǡUݴo<@]c= ť kϰf2l6z+9Ql o^pQc_/(72 rt>p+a|=6cβ.Iss)m3̫^]Wown[Pr][h5 < F<%q+sj v.?ww#w" 9c<][RNE8g,vO(jXzw 9J7b>3V_/rVE/zyb;jcJg1 (A]FP)n ޏa;Cu8Z5O:m5iăj1XdǰbKwHiU^Ć{/.X~mh qS{t')'bc Ws? {;X[o8~cuvfA,t! Z:ȢIl5 @:wut$fz5jr"&⧗~7+kUYVTVd(OGGG%­VW&#LIo^~aݶ QJt"KZ̅*qNXAM$N?~]|ݝFGϞ3qeT_ SZET:(J2x nKQ}_EٔEܰz2'VҊRM5pTSm7$^yNF4*kcEUfN2#vUQ8QSqUT& ᴏ.n|TCeaO%$1n%Bljī, '뫷 IpX™ -8ȘtΨy堬;%=QcZCdxi~Y֒BZ{]E +8C2!'D -n*}z}Q<>a>+H#,@9CToXD †3&L4LC0;d 6{ރk",ѷȘh 5F?4SaFL1 {$[-IKfDk%b??#/'4pm[̃ MeW- Tw{Fwf|_qގfص"Դz~FP/ ؔxպ1[Qvϲjýq(;沕H <'+:Mt $&.n &ȥ! !ups'p!Q-+&,"{-a'1X*CۅpQyĂ[_z{-ԧ^8յ,Xzmc]^7BדkZi}:: Bz$}M0dD["QCdoG lsUg(N݅ǭyzvwd;kF"hQ,^u>\vR @ݖ6Nؑ1rڵZF`;Qw_d#g񜂜,p2[тEyؽEM, M2#[C#wBk< q{~#kl}X6H^~qyqωud8h;g%ɄY%.()h4'4ΥuDkexN;NJ-bF]w^nwH W꾦C.k ftaZkfQ%e& +n}Q@3KQ wJ+,jɘJ9/sOjk?~_ e]uC ,01\VPYK~! %:|N2^FU±spPݍ޷FO3 'rP~YrІo$bH ==-OUR+bDcr==iN>|),BF[xȲ$+x&c;~t[l9mi9w4 sX3캂B1؝?-j{חsa)nDD9/!N;De~3ׄ{mvTmrdT&YMؓq`m8Wj=׸e\ka% LTWbc=2~WxjWX goN&6DYA!27$ԝu>Ϊ[]!*lд' $i18n{blockPageTitle}

$i18n{blockPageHeader}

$i18n{blockPageMessage}

$i18n{requestFailedMessage}

$i18n{requestSentMessage}

G=@,Ҹ8:i9y@?ʡm9OcQ=x}3~ c[>t@ RANij=N3:Et+\/c&ȇ%} QZ!{ׄ|"W"k0o ҙܞŒĶt 'CJ8$mJq^%RΓŋk}8 '[`|- -au<=olhC2j.^UP Ongmw{TU^G|uxѷf\5k.n3dK 86&bvR639)n8)P/IXeڼd< ½}zϲ50RGӟIuނںVUWl6VFy[Ƞ4ϱlDAQi41L)GpXCzb'=ޥ|(_MӔ /,YdWK杧b!%aoc5Tݢx VcˇVt}Fy/?sY @-!wMk<x_q?WX Lҋl u`έz-Uct=&I>8t3 &펊JIb`!9@08j?~KbxkYb*xç*D_.c *XEzA12p:XB}dɦ/p=7tx ;кl'%).D/ήִ'XY'F΅4$2!#m>PZr!X'rPB /&; Nd qB6g+ae"B+c$:@,g0m"ёuL$4EyL-iĻ=ěFj ~SBB[N޾Umahh/j!9'n40-N)>bѡ.6B&5Lc6 0 O_-O~Ŏ玀a)G LDոK]359݊|,4ŕ"7jygtr/kddhDܗ͸{*9Wa#6]`,aX@2:fdV:4%yIuQPHw!fh\t4loZ'W7W]\]|㏋tʼnW},W/_w[k&0YxoTvOt:&~|cn~ɧsevK#>]uv^g 5|NZQZdfk}tkc)=/RVϯ>U&Au*Crq잼vmЭWW<~ϻƷL({_OV.uxhTt{ew(+z{>m]]kպI[;cZ/s}CΫym GmlmiV|ek|yw>8>͛Vh=m?-&?7' W v?&a0@0ܴYD jZò!@yQR8CEHQnn9dROHig 1'=oň-ߛs}m$cXgn.Autn͠sz36yMN턭C`p{zOŎڰ?yGN*K,@{pre6]=`Uײ<0~?jFM9` _\`]4`\iMv|%򊷍~3"û[ZqfaY\Xn҉Dc"T>oe}2s &FmvKbVDdrtHlM%=H[2+S5q :&N1絥:Z57aFF |-.良A#p(z2 ۇ\Ru > ['er% sMpP< E)5XIȻUF" ȋc}q_qTTh>~7fņBym c|y&P×3K3x5lz=Vh4뙠a'K1KLOh!/뙠hq14f ܅ih|@.n$^I`'ˎx/M<"wq1?7D9HaXOO,Art;2|E%ȁ[<_8Ip$U1pCosʒ0BB8rAJ̏ r xU.qH[@Z Y΂FU5V {,!BzU QAB⺽TT)蠹U?PofUOV.i VbrP?KѼM<#.)D1sKrfnl Wb7+YGOAv`j\x_='=%y*D;jҀy,vޔwK"tR}MN"UQOj֭z0 R_h!es ՄMTԇB *!)J^,)0 4l;xS<5{nEҰ.R0 >AP<@_?!|f",;>NJ*~&ǃm͢'XmL@Ȏi[:b&I MG&v;~o Ngb5э9a+13(9 `|޾tǰq◰'VJ]IՒ_Y6]VD"0Y'JY9y.IAt@L|Q) !F X jD9ěcy s7W E88&O~䋤}-]k!͢=L3XYwﶘMW Tw1zɺu7Et,X9Ģi6Vn`1Z)OX.T>,@zoəYwvJBIl^sMx#]NeqiD18a=mhxm) +V"kzyx"g*_֗#ӫ(Bu)xo|xpN>02>uG!JsMg6Ua暢׸𒽅dԝt349\H-RelNt R\yM˫e}}y9FZgMk`rx{Zk͓f]{M|rچ+YɫNLKhgMUg=tGyLĵЙČ;ķ Ͱ~:v-9/Ž;|H$}--l"2Cn?_1____g㻦`ޢکQ~-4M ,~y¢HoˬV?? 9t欺mX/hݳ\Ar4]% ТaNO%l/3M$ [ b<. yٱccWr顁t;S#oa1a±nGTIobq @JڕzeHj ݶ=VTBk{iyKi {p4p_w{ 8y s{ GP0Y!-%Nb"hL89ay:CG"`gUo!PSG5d1d;Y*, &%(.;A0Ŷ@-Y 2ZsK!ES'C B]tڢ2xz=)bUr(<+QQ$QcPW9U(΁gN"4ؑ&n~ nʺt ]RFVܗa`e+aS{m~ Ay0ﺩo<̒Qyۖ2[PWlOrm= +P*AW[oJ~SU#$G=< 6$^j$'ͱ*P;7fp%8oD*ҚR*eUԠPZcfpL5hiU~[`ͻWl+(H͔@,!-pA?\]%0;q Y]e?u`kaj^R-pG 3h K+råO`d;mf/_ҿ.A QP#Ӗ`M,ͥ ɌM!Dw(O 2㨏oͶAx^ڵkk{O⯙p3MpH8r3[*p5tA"ǧfU(i;t]sq h~^uݟ ^yS^Fwp)@.~bn`SL?LA1O7!H[ՑX@0JǷ <};(0^i\V!mJ7%ϓ؞jBuL^^{+0 ӤbrHR.(|A`HzHIg=p@\qmPiB skA\!E£[B2(uMmLq^3„4F5T4H lSPX<PR\ @u4ޗ^Z\(a)\Rg@(>Hj) ,4Υ\Hk> HXsӛ !_3bLPl85e"O?PGtlgyfɗ,@6]0lRf8'$$/xyt;$0Ah9_tR;v$B܏m /%&,m i6}ԛ AikJ#5p/Y?RV~b- ז8gvQ!B.$Vuu9DJ}Gu t{&| :eŽ8[ݑSQ`[x]V{M{ #9n@9^DZZ//[HJIL} ӊfM׬bϚXFC'Z`pv֌rB՚U}17!N WՂSZT9cVv!978xsiZ?\W^V$HIkys+梍6ܔ< =Tmŵm뻕]]9Ǻ 33׭߄BcOOϻ-yZϏ+cЕ^m 80YAD!W/n!\_Ş/SA":s?^3$Htbw&C:pַ0̟ |0Hi(UR2eVMo6W,=%(qIMNA45HA{lѽ&g8y9,Tb]X~aQhUJt2ÓAP9B0 kE-1}P قYL ]\52!ivrQb:pf ɔ05{SL!o$Bd-L hQ{Vv5!> >axVe!-c2ʧmLsY 4ÜaW^8Bq]\ѵB W5XƜ((nK*GPqw|~p sxL?P\BZD BXE`uy rnpoQ(mYJImҺ1EҚ%nr }ifW?jј $q喕 +hKMf:wt;qVR 5Zp/&AF8d,1Sy5)N &)b-LЯ-$j"0#_ kBy;n;ֺnUK4C e86Ԧ崽j'uJVO5?:3T+HMOw&4z3D'D[Dɇ.҂$kvNGXG~C#i2Tp y\LtH@/INe7|rJp V+74QV5uFApІQƄ>EȎ %+έ߽W9U(YlTn}M3N{V"|vsX놯kY6>K׊Gz?w1t{HxƜz:Ne Q~6 CGWř8JN$Nߔp 4)x'5FB\㟮L"<d-sfg8 5R˜_}̵ډjJEрFzY:NO6`saJs2U{3Xغ@(GIa6g8FQ@HZG^^ݮ`# LGάJhOs2=Jdޢutp\{/T52R*'r /9ef,Vȕș772)٦FH'ܝ' %':H**X-M#Z3 }Vν/TVl<Fz2%ed#ϔ Z>h0,fXjWhq`*cd}gԁsGI<SA5Jr]ƞ ,_fLkfpA ޷+Q5:d]ּuv*4]_ZZYU1,^9>x2z}*CXިt)Av r+jb4~8P/;o!@pqQIcMQSk?OhR$,QfIo #/dU`.[|Hϯ$]~dy(*??p0,6dsn9qkƟ[7R֮ܽz_QRڡ أIiێ=2B2ȸe;jz&5ݧp暥yv0v)!\7Zq"sC1-Pnm>ODvzMA<ޘN,L }C,;a;;>Wujitcɯ==+GwrQbN"Jca@Ɠ[ܻx!aP lJNxnh g(r4^,\Y /t{ QUƪ]p9]ZTVQh,&pn>D=u}F2nE3S"w7ttqM$Fu2"v ^1p=vX]ͨ 3?:I6Ňbi^Fk DV(WܦŦ * |m%&l\V& wx vtgg^PdI. SA=82^Xa&̄)yxhf>0> > / Yn#}Wp`Ԛ[2hdMV/_v@)3fII"}SMϚ*Ω*l}.+Â/`UrfY\l$ j -ó=kԂLM X0T["f=6DDF"Yq" K Ic&S v3O'l!v.Ȣ<[+Í\\9 42RA}?cl=~lV"Dμ3ˆ|-ScFYKD3xGffIծ9}]OSX|+l7F'=d7nZ+¶),Y95 T s#R=OicDL,e~]K(ZHK_pc^N wuB%;pA-(D, *EvXGKY& 5Q\aXaVP`K'&'i=W/YQ5A]sDWB957J-ϪH|Aq˚LT\E R+\ԝ@?rYu cKJ XEDjZ<:#nyU 3Vц>{ܓD_UdmdzdiYVj9 ݷVE7VT6EtM*p@6ͤh!oNpL8T2ǝxҫDPfvtDF`-.c 1>.~6#A?r+Zc˱u(/p$?v~턝doԀ|iPM77 &XJyK]fwU->' Jw$T=X[fGK_~8g/签ڝ)b,( q3:%}v2ǽ vCP{G(:a~,;Ϣ9\+q4U[ҡOXXKo7WLNZ -PXqQVP~ı v)-\B!%p}Dr^oz4\)>/ d޼ ܖ NJ%^qmJ4wPb{Vh49Sr Z*g˂{+` nN_iTC$:SdVź6bapŎ16x51`',e{΂/~[e`>ƢFc`0sm|s!UjI`}GZ IaB-zJ(V9%ȥ95t`e$3%}Q89:|ksKϛ/w "H&xʉ A 2:aiݖzaIS3ɺh2TTnӇ*'@IG%:mS[8d #\ƗCc?Qe7{.6ߛDJ "XNԛ&L(ҘWS˝MCMzIE(^VQ[g>q.S*tt%#GØ]>(P^,78`#cKv;m+1L1MAA^SU8rל%la5u]njY:LqxE.r JMO ~AY}Ld~3>i`g>4@\MBUlhrSnw֮ghMNzCo-G{Dǽ(ɜbV|=v{Hnsj:4}{sĤK͊,6QhD$P(s{uz;lVbp7 I0Tz֋nZo&u܈/+1ry84s4'W>]]|<纷] -k]\Vlso,U.k6Ҙhi=g1~0 `{Ywv F ڶ!M#~K8U4W[V8Dϯa6,ٍ+*"LNߐgWԜLa@wgw VY]!!Ք=p6%Mx;a"?|bϜ֏')Y[s۸~@2%mzxkǙ>lw<y$h==H");f ߹A99NƐ4?=9(Y$H3r9q(РPdG91ʁ,w3mvg9%ӫ9P=/SPNQlYE2Y8#ʺ5 B3#W&iCjiPOаI>oNH~ w:hN$ 8Ңbڀ+$h"нWYW$W@ #E]N9ಘS`Hwta"_aEdcLcY)`LzJ䕡ikF^`/Ma̩`%^AB54m8i0ntnΓ Vgi:a9[:g[K}dZx}܆|53wv_q):T޸wd c뗹a~0k`h-l8qE/*f7ւFBD.E ׆ZSG"(l%7i| f2AKT$H\FprMqSTo ݺp[1?*v^#Wԩ7А96+[0:NVVR&B`>ŝ,`kL3'El"Ś^Y?mѝ~~Moƥa4uðzq<ɴ;Qk .]̆P{gIޑm4҂TfY16 d+91Ki,1Ӗ_;*Z x|CCJ 31vb-jȵ[^c2ۆN~p4rg2#eF[9C'co"<4qļ =dVآ,1y]Ekw*%%CfZ㲵~_LD4*F!I!ۖOIU<D$AGa)Gė]Vw;g%)Lؒb+7w'[#'͆w!ůަsl|xDRq=o[&:T-oTՊzl-yFpՂ+@9v|7lCͣ@Cf%j1 χ^9>֛4TPGƤY9fyL}$A]gb @oil1Wmg|-mY% 5n݀Z䨾-O~ԑϔf/^aOF9jk{ Ib bp&_.GB! 43 p^p[AP7}fR~in۾б>wZ<+:6,WXtMEo@'R'JOQ,\`C E[ iUazzt|炣^Y9Fí?G5Rth@<4dV`LxsGYOn}:O'$Sg~i'\ADԜOQ"7=f;!ŀ.7Be&!zjC7a6nsAƶ?8@>ST7qP)}BS$`+n%҆.;D96lB}wXdCTkWdvpzJ$WDo I.ȊG^2=rqJUQ\R*78QBV}Y}T=[N dM?P|\?$hNDuIrR_xN[D~5~*֫u6-|AwKZ2!i@ڍTv8#M*`p4ZwU7J4=6njoDB<)S'ʿQ#fڳsgWݐl=?$-ndUYt{æ;G1Rruh(ǮB$r?&?KgtOX/p _4mH}ߊaLcX@ ;^q3pS:YG(d^Ko|7oq_~5vno X[o6~ϯ`xӮc{P8EDlh+RNS=7lȋ 2R}WkC4"}=sņ VPb5əfe׌%1kVE2\%bO(J}mS&Aͬ!)dR2#\hrLȒ B+Fَ$+6۹WsU$$aԾQ} 8Ij^(쮑fdǤ00Hs#|DAƳ>Se 3R'hPYC0bgY0)Q ~|_W٦&Bw'eJf769+܇LcP=]Բ׵or&2  g&O7/7I0NO|Wd.n)O'/Lm·û:T4\$ T1oYK"S2d1$RbO#vNn6Q,Vbg ҂rB?>_l*mt/p(e7#D0PU\KiNй~b`)|I<Gn?l UNЁqMoh߅tMq:aᅮG' !(2[bmAk>,wM `:S- Y/jxmdcI kd-vδRQy üB@ՅzJ3yV+ϸN<^w0kTDz>&8 .5s%fsX#nw.Т0i4] ho v@KS8(j48gQcCٕP\J~8ƿ).àTl6A{kA^jW.G_Qx+Y;<  gA)щ>W~d cK~.J:Tjd3^QdV^m @iY ##TbS1-iA@~^*K7d>(Z=Y sŕgnџmORc*] XX̘4[e-&2s f*)DζSza.mסDiqƱpӨeUju#9K>D=[v WPAIIVm(8څ{᪌ 11sBu iw9Zl95tF>e5TVK'+ӵ}pp:gKl6g)Tl}b;u²1m뒔BaDG~Zi=(jMmTa_%.'A1G`<, A?a[ bTd6ZC K&RALKC;eb:20Eϱu<@yHTT)%>5EoeKmIVJQ򎥌XX 3b`qzPFob:2v{vpZ5Ugv67}o(׶5ܞO<2 MQaǽ7Bg\>M/M,0 2q'J+N3|Ys#Qf}-NRR!u1j1U8N՞4 M+TR5 a6_:kK; 4f ӁSkmѶ`;d?aEj*| \GsW$Y+3| ׭P~JM_}*L]EiiLf̭\=0.3?Ԯo]g5_^ 8Պm~t6 o0bGҊ:.E|njHDhkȒ4y4$֒Pn0+VjDc P !!0B\vHCEvgfgF[UPcOf Sxm|gB '.af- BBF:cSfUow a`H#h uH`y.Xz`FϤUZ9br0.˗z,`g,Y O>*/ &,TFAI .b` >Z#OSƏYvVڥq'h:)( iy_[żR]鶈5<9>,:)GM|{iaWSEm eQ5,טAQhOioeAO0 >  v@pQkDLSTwvC;E좀zg5elkB u^i`ypuERl\]s 4F!EגAI`(#lv iP$rBceT P1'vH9TK ~8 ?vn=0p#3gbg!p:lt$;Ô"ʎeK+9I+1Z637+98:&$*w}C#QC]ZLISy~"EF}Un6+SR`:".6(FHYwDJr>X{oMr]xH~/,5K}acp@ fl4_ a2z] ZBPRmװ&SnjrF2^r_h!s[-<ϥxVf*W;76/вoQ jbu{}X}U,y^VML }:nB#z̮]WpB @m)56Rg k"M(LRՕޔ$I)=y~ka2F!{ }*9lb֦pmFg"{Aڒiʤ|cbȌK҆Le&I"G*swp-Mq2W"6Jx!瑚Z:[_%#KC7 1_Z.ŎȆ3\bףJ$+KxKj~tڌgqsª]PpU=vG%K~x'Q:-6\ݧ(>x }7Dv(l蘥Y5(ͪ8}"BB&740$ǁMs9y$F ܮѳ0u4iMi4LZ)⊨p=4bn\$;`v||X(,#D6[ HňZ|mA xdL ၔg0:oRi}\y}ӎwZC%Fuy`+b]5  BW>JD;^U-n?y 2F?$OqH|B%M{]pY:ڴ;.z// 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. // This code is used in conjunction with the Google Translate Element script. // It is executed in an isolated world of a page to translate it from one // language to another. // It should be included in the page before the Translate Element script. var cr = cr || {}; /** * An object to provide functions to interact with the Translate library. * @type {object} */ cr.googleTranslate = (function() { /** * The Translate Element library's instance. * @type {object} */ var lib; /** * A flag representing if the Translate Element library is initialized. * @type {boolean} */ var libReady = false; /** * Error definitions for |errorCode|. See chrome/common/translate_errors.h * to modify the definition. * @const */ var ERROR = { 'NONE': 0, 'INITIALIZATION_ERROR': 2, 'UNSUPPORTED_LANGUAGE': 4, 'TRANSLATION_ERROR': 6, 'TRANSLATION_TIMEOUT': 7, 'UNEXPECTED_SCRIPT_ERROR': 8, 'BAD_ORIGIN': 9, 'SCRIPT_LOAD_ERROR': 10 }; /** * Error code map from te.dom.DomTranslator.Error to |errorCode|. * See also go/dom_translator.js in google3. * @const */ var TRANSLATE_ERROR_TO_ERROR_CODE_MAP = { 0: ERROR['NONE'], 1: ERROR['TRANSLATION_ERROR'], 2: ERROR['UNSUPPORTED_LANGUAGE'] }; /** * An error code happened in translate.js and the Translate Element library. */ var errorCode = ERROR['NONE']; /** * A flag representing if the Translate Element has finished a translation. * @type {boolean} */ var finished = false; /** * Counts how many times the checkLibReady function is called. The function * is called in every 100 msec and counted up to 6. * @type {number} */ var checkReadyCount = 0; /** * Time in msec when this script is injected. * @type {number} */ var injectedTime = performance.now(); /** * Time in msec when the Translate Element library is loaded completely. * @type {number} */ var loadedTime = 0.0; /** * Time in msec when the Translate Element library is initialized and ready * for performing translation. * @type {number} */ var readyTime = 0.0; /** * Time in msec when the Translate Element library starts a translation. * @type {number} */ var startTime = 0.0; /** * Time in msec when the Translate Element library ends a translation. * @type {number} */ var endTime = 0.0; function checkLibReady() { if (lib.isAvailable()) { readyTime = performance.now(); libReady = true; return; } if (checkReadyCount++ > 5) { errorCode = ERROR['TRANSLATION_TIMEOUT']; return; } setTimeout(checkLibReady, 100); } function onTranslateProgress(progress, opt_finished, opt_error) { finished = opt_finished; // opt_error can be 'undefined'. if (typeof opt_error == 'boolean' && opt_error) { // TODO(toyoshim): Remove boolean case once a server is updated. errorCode = ERROR['TRANSLATION_ERROR']; // We failed to translate, restore so the page is in a consistent state. lib.restore(); } else if (typeof opt_error == 'number' && opt_error != 0) { errorCode = TRANSLATE_ERROR_TO_ERROR_CODE_MAP[opt_error]; lib.restore(); } if (finished) endTime = performance.now(); } // Public API. return { /** * Whether the library is ready. * The translate function should only be called when |libReady| is true. * @type {boolean} */ get libReady() { return libReady; }, /** * Whether the current translate has finished successfully. * @type {boolean} */ get finished() { return finished; }, /** * Whether an error occured initializing the library of translating the * page. * @type {boolean} */ get error() { return errorCode != ERROR['NONE']; }, /** * Returns a number to represent error type. * @type {number} */ get errorCode() { return errorCode; }, /** * The language the page translated was in. Is valid only after the page * has been successfully translated and the original language specified to * the translate function was 'auto'. Is empty otherwise. * Some versions of Element library don't provide |getDetectedLanguage| * function. In that case, this function returns 'und'. * @type {boolean} */ get sourceLang() { if (!libReady || !finished || errorCode != ERROR['NONE']) return ''; if (!lib.getDetectedLanguage) return 'und'; // Defined as translate::kUnknownLanguageCode in C++. return lib.getDetectedLanguage(); }, /** * Time in msec from this script being injected to all server side scripts * being loaded. * @type {number} */ get loadTime() { if (loadedTime == 0) return 0; return loadedTime - injectedTime; }, /** * Time in msec from this script being injected to the Translate Element * library being ready. * @type {number} */ get readyTime() { if (!libReady) return 0; return readyTime - injectedTime; }, /** * Time in msec to perform translation. * @type {number} */ get translationTime() { if (!finished) return 0; return endTime - startTime; }, /** * Translate the page contents. Note that the translation is asynchronous. * You need to regularly check the state of |finished| and |errorCode| to * know if the translation finished or if there was an error. * @param {string} originalLang The language the page is in. * @param {string} targetLang The language the page should be translated to. * @return {boolean} False if the translate library was not ready, in which * case the translation is not started. True otherwise. */ translate: function(originalLang, targetLang) { finished = false; errorCode = ERROR['NONE']; if (!libReady) return false; startTime = performance.now(); try { lib.translatePage(originalLang, targetLang, onTranslateProgress); } catch (err) { console.error('Translate: ' + err); errorCode = ERROR['UNEXPECTED_SCRIPT_ERROR']; return false; } return true; }, /** * Reverts the page contents to its original value, effectively reverting * any performed translation. Does nothing if the page was not translated. */ revert: function() { lib.restore(); }, /** * Entry point called by the Translate Element once it has been injected in * the page. */ onTranslateElementLoad: function() { loadedTime = performance.now(); try { lib = google.translate.TranslateService({ // translateApiKey is predefined by translate_script.cc. 'key': translateApiKey, 'serverParams': serverParams, 'timeInfo': gtTimeInfo, 'useSecureConnection': true }); translateApiKey = undefined; serverParams = undefined; gtTimeInfo = undefined; } catch (err) { errorCode = ERROR['INITIALIZATION_ERROR']; translateApiKey = undefined; serverParams = undefined; gtTimeInfo = undefined; return; } // The TranslateService is not available immediately as it needs to start // Flash. Let's wait until it is ready. checkLibReady(); }, /** * Entry point called by the Translate Element when it want to load an * external CSS resource into the page. * @param {string} url URL of an external CSS resource to load. */ onLoadCSS: function(url) { var element = document.createElement('link'); element.type = 'text/css'; element.rel = 'stylesheet'; element.charset = 'UTF-8'; element.href = url; document.head.appendChild(element); }, /** * Entry point called by the Translate Element when it want to load and run * an external JavaScript on the page. * @param {string} url URL of an external JavaScript to load. */ onLoadJavascript: function(url) { // securityOrigin is predefined by translate_script.cc. if (!url.startsWith(securityOrigin)) { console.error('Translate: ' + url + ' is not allowed to load.'); errorCode = ERROR['BAD_ORIGIN']; return; } var xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.onreadystatechange = function() { if (this.readyState != this.DONE) return; if (this.status != 200) { errorCode = ERROR['SCRIPT_LOAD_ERROR']; return; } eval(this.responseText); } xhr.send(); } }; })(); Rˎ0+ZBha$g3"i,|9f߱)rWu7vRa_{jL}j]4؁< |P:BPbYz><~d]-5 ]ZPĤTR4qϟ_4s\g$= @ u|f,yCmbk#.֙a[/_ǡe?$;72p}i6 &Ps+:IVhm\uA (qa Deʃ l@9{lN؀󾒬N`c`š* F qi$Zl#EƥC#WjkUZ1Lvir q{w QQ 77fio>ڢƞh94hJfN$&rKޏlqufrd9A$@MzWG@Wn0+਽K- HW&WSJȕ𿗤([NXKάFå_ q[)T$_f [o7`A(kH֐fDE),wM]qŪuǍ˰K !7,3p6nҸ,cr?ͳv]WC EjqNQa _0,3`uc8،[yT@͸gMH]ɂG4F]$f5|PƓSbeXLP%P]NS6+geij43l*Ϣھͳp6rXLi׺ઞkC5վrB]e_LJ_w?鷇\5izv.@ǔJA˪roN8(U3!?֌wJ{,(WZg[TV3iXRgK.c;bp}\rTp.%-3{h_OD\ X996Ea7ў~?,R7Uˏ+R#bIl~/XOB[2吧K|V1~3UqJPcPwW8$bjsy>s(YK.;mhDEZnZfĻPy|.CK0Ϡ]?_]V#41 Vo0޿&!%J |d01ML 1>LƗv:s~5iVj\޻.e8/¸o^~ )(5z&d.Fp"%@ -7wp`ufbXs&K`q|ҺD"FE421S0A)љ ./Fg_gQ7<ðd(feFT/Tv><^:l5GpfEԥG[T|N~R2-$*LEFS &86-z}Ɗ0(>7?TڥUJF"O~[ܭ᜗DV [idH+ф*v92nl#KfI–ăoJmc찙$ޗD6){?WM}-qW/3ԲT3{s'WjAi=2=6qOm'FϕLW7sK-A+$B u[ÏTKu6q]cpQ[5b}X0a:.Nۛuҡ_s:q 6+ƒ*mT[HSB ÔI-Jv&ޚm~נԌG-Z]RBWazd 3C?*&6'«qriuÍ(o A`@NV^.q6#["ق M^}.=:d\ Xmo6_q0`+*;I׮YWl@R0tPFRb}wԋ嗸v6,5V7֍\),:ҁ3M"ܢ՘B?\9PJ&IC"48LS^"xȤ /t:8- Hraa0,xNϓT/B{B ;, arD?bv6Lx8ia3n8^_;rK{7ˠ??SwQ$Q ҎX`th>D!bV ."X2p/I9W3>RGw2 ~U4z{SL {0ŬPyϑBv_&[}[~QmBdld]8gL#L6X*h'F;YZ`AXB JW*A!6qIG~lsLnb3nJt$=G4A^S,&,8:݇kF3v [ ݆w bEw0hMՆσ:^j(8"Vt?m_\8 nRsvNuLN,=8lGJ{R {jOڑ /bA$^}E!ꕗjpD|u%haZL*o!Q9B^RV6|2IX(X > 7,d4YՓ#p`hLφZxyÞpGP9R ŘƋVK>1c=':3.Z pUY{v.~cʴzژlCJsF+2R{k'A:#Df2apQb 㽈$Yd}a8[M꺦< sEfs,x0 z(LahŞgvAe7[7umܲܿbgí/hb =C-@ ^&q[eXza`;ٝc%4bJf4?^#~dy ܋<<`_͚}8a'Ō=<=]=,Pkkɝ{>C6^yjڂae9 B-֜<:X7[x!Eݎ _Q̳sO.3SD|8FMNwF@vB^:bu! e4|5"{ևm2LgރZ{ JT:ɟI(T Q|E5U1WBm&Uہm8Ew\g(|Y6 **Zpn@1e4otLuk?p*Up@cXC`ϣ^ #^]F>ESO7Jl^Ē uÇᔟb3=iP+<=?+}I7zr&?(RKo0W"ivKaYz@B\ 'TUNk Ka(9혾bĚ+Vܜ3$ئ-$'؅QfOXCAZ"F#ɵJ Keyq.9M_N_9hEcvLp6&Y[S#7~PT]x0$즊uQrlkhs.jw6Y\ttGNr-lIɻh\/s!UBFE$SL޳,_#bJ+D)SFR1gɂed&|VuHSVSMRZ CMSQ3r~vrzy}JeQ`rs+~󡛏ýȂּػM-錩*oQ8З9Wfhl Ib@Ÿ9Y1sXݭcfH۝O7|\\z^1\%,GŬ`2Rw" ^1ļ7 /t|0WHS$w1$Ea%ve\Iβ {RC&tru?NҜ*iN sp3*ń?t.8Ono@譁netp7\A6mX)VhVAg.~[KN(PПbv>]} ".W$?ϪhZPYb ٘qzLc;븷&h4 Zq ]&JG]R FLJ!kjE;", 'b*^vc(]29DSzϡA\_DaJ_ :+Qs[Ӏ;i˸*B݉-?ptJAf74[JSvCh%Q߁Aj|5P]!֢&gl.xS)F92+oYqKju8 m gWLF}Q}O$&E oNASQd 9n15Z-6^1o]shEo GJ`Hs;9ʉ:Kw MMgyΗ /i񄧐\fCKvCgpDv1~lL#q84֠34|QȎHv§'B,-An:4bZv~!v75-9cvNYbUNfSIK)^lGG3VŒOon;@O߂fSOZP ֮dNq{1>Xy>291 vk"yCݷN%o0nZ󩽴NDp ^ZYRJ6CxSbQ=F4^[!U}B'Fn4x" \okP >@ .ggv 8o^Y;=)PyW*3?%~zDgX`T, ƭW.Wv+έK~jF&@#2>̡ m0r`AC"nk:C6'POfe7,|WLr\(v\ 9*d9(6\@捡[l_lFs_<5TM3}}C6E)DhV_M;7(:Zc~lC-4'4é rbهJ x#ƪy |F7&|mJ&D=T2f9KD~kے  Ho7m±;X$oWOhIЦO[8eW1+k$EV RHM@}HzѲ0P}9wW9>^]8 n64] XKo8Wj4ʊ6=mZ[LhR i;"}zزItOC"f83w9WJilIwB~@cf|>#gs+mL2^KDM!Fu$R .jZ#O+D$1$M\2%|8dt;? ^|_??¸z{^pkq JyDP9-NA |$6`N^e0:Ks0u'AK]21Ha2"A0 2&tl=]Ē)-)Qp Ert:]$7ٌ~it3)tHt|?7N oU^r RפMz ES.VB6-Q3 PƸ8[OL!sW[fWTN:r2ܥeN-_@E\Td}﨏{GDUb?*6QEm"2$1g dэ[gh2 "ifT O 7nk#2IhiRENNȉy>蛚#~ dUr0%f`4=-a3*?E5ُZ=' _5º bEpBA2ԝ ݻ{͕urqv}Rm3MPtXI7^Z`&t*dtwEǞM q1%3( ?3p6 x-]s0CPq.t_)ZĆv q! jLIM^Klo%2Mn)ό.TbGċ")·#m]?@bw;Cr!#5*εo$S\z9G91n{ ^VQք+7e+Z~ssPYpWcn\|閩oE15STBd^! KϧussOriŞ }5"9Gi ^."Xɷj8 (6d} i;p]mz;3a7Zd\—Q#8D~=4:$aI>9\qbf9YK"Y"6ߚz0=LMx|զkXW.xݢn)yӹ7[lR\e/// 3nKŐ|K)M 5Mn8v[]wQΖ+Gn w#;ܼ_U7FehS|rtjU=rmͩte( j0ce*e*#ZKmZ\e.\~{Q#[HT(;נiXbtAsE:3;el>]9%ڄbɾM*v8b5Q*52/>Lvr]_V38 恲dءŸh4%,%)Iv}uWdP6Uv__;.(,rl]/ʊE, bV4όrNE+UBIR]@vCbjxMNI%z$ rKҼ\) Xd2_9lA2.9e5\S2>i p\HF8ȥGVqA *񇱊?p QB珓R4r=U .7N-q.YbUu>x3e/~3D?ںX}V$GSRh !ٜ{V4?O'B@-@=@50U!Žk|P+ ,3VÁ8UG'e HCՐ,kC%ErFMdE59=b̲kePC~!nf"wœn?<$ pҜVfAP'`j%Fr өt6B-'nҔۣ8ʅ-+O^pnO^EZi>a gk5$8m8~FE1|*8ermmP̤к?h']dsͺM%_0S6Ҍ^[EZ!Ej1Y;c)NckMlgl71 ejZ A:/+;) }VUBW5TP{%6Nw&Ĭ6~)" zȆ`#}L~<qLhduЋo^,2#r0V[=MzooֹuBԹI}F!Snv=y_4(W,ޕh_#ډҾorzQO5q)n<>ȅ&d7bWM+ow?F~@z|HE؉`mBx 6r_aq&˱ _FIAd$q:nݱ)$# j]Ŋe-mn,h1<\/Wc,A-!iyXR#W0`_o~_XV6mR˥ݰ6K` 4tGM -0HB=dU l Nv Vs"G`@q|fᐡ`e;/m u㫎;*a&.IreK<= x<L e9 ઓ]xP}&w[N{X] #.z-q``0t)I \|6!/a'Tn0+5BG_rJ J\I).AR"^Qv"@jwfgv)l,+I/{,ղY7{Or`ѡݣM` ?+QcsB,iV>=~f9P+!2 jC÷(Lo'7&y5u~N2JUlOHXl>uydZɤChUI ěE_RjT2耐 1ÅLa>|=k) JM;+x'HkI/Ia{l! ( W$3ஒY( JQ40vS+)TRgB?4b oMO?Q)itݯ 0gx]~kvNĂ{AF< {ԩXFM/UK6lX”,/`5ײ@iѫ>mN^…˦ 1IFP"p_~N3O~[_ =[A.4-PNG  IHDRaRIDATx^SA 07;:usKHjtAa%81d/T8|!姜?S:[üIENDB`PNG  IHDR D PLTEVtRNS@fcIDATx^I ѽYJpNBW+JpT g, 3 2 s B}QIVըju}0cicpx3^+o:IENDB`PNG  IHDRJ#+IDATx^10Ec{.T¸L vqve1kp #"y$\^#3tT *ath2um!D`(BW'MB#bi@sP HLT*3shsãm׷3{}\#:uBn|USgL4ݾM]5ףmIENDB`PNG  IHDR..IDATxO@p.p'eNZAb71 J>]#z C N\UR䌛TDBFgUvÌI5CiFϙ"I9:Qb&(JN4~lץdwV [MWEGnPub"_=M`t?[?ѧ_CCqKXE 3m$!glSMeMQmY;vu챸_@ qLh3x~QMbQ6ڨkV{&k9@7}[+nX+}O,J(6Rړdr+S[*QfX)*I8Uk`e >?GЄp֯%i[w*Mr|_s͘4ʉi8-]CiyKˣQ^N^/{N׈ά;>Zŷg; u J)WLW<$J2Pф̷$"ooϏDM)IYL3+WQI(#sy%e뫳wdR 1`ˢ &dQeql4&2} I^@7N#!H KdI X,[Ҍ֑)ym5@o002+!o G^2֠mɁhA"*)u' dd7ow@ǠjT٤'lBT؁/r];ٙ!2w-vѺ.޶9Jd\pQK@-956'RC8q!괣_HhJK/c\B RVo{2';鰷)ѨJ&7-kk [~J D8 K9M8&kVZs0tյM*NF&B&DE%"8ZFE_puӇ;]H(AŔl6: %)4죡v*rrQ+6'֒&-vwf|UۏsldtZ&8l EK=7^Qi1A0ήK(&[nd'W^aBX.)C}_ZpcZl\=v.2~'{BLsŒ; dk8nh{z`C)ʃzM(Idnf`̌DNT|BΫī6=Z­&ѵߏz+S8 dF~YM)k&P:kֵ~(V"#e'DnW  t VeY4֙.\2*z] OetҵOw$f>w쓭=kƈ~i@\^͆E4O̸Sۅ{Ks*1 pHga47fn=;}5>)T[Nf`V30CG~9+zGI|ĿV7./yY BhKH>o6";xQ<\C9I0VA .}F+a!iۚl#xJQ k PO9Mon1cNfQn"F64V !(5?OtL/t~irҒ\AE .n% r+aiE[@[D,E6L h5wՏ~3I_:]1g~pLV7Q۫Wt6t6\uη~'_ˢW^U#b]LPAw!̒m8۴r)8"ojs`bx_PNj֑twyI*{Gx催<,L YKdIȜQ &Gi6:WF }i(]0כ<Cc# XY Zbwg%NIgr n9v,# 5xItb]ʂ51`X;ӑw<݆?Cjn &sP8mpx$نXNK(ݥMOnq>tkuQ{<:mAYw=e-qp6Ua_zl#.{1`é"0tmt7{;UP5.MFbi75֪q% />ۋ/XzxK6Tz09G-pj\{x6+Z>l۪F9p"鰝;C6O1U fc⛸BohQ\pc|'cs~ҁy}{Z^ڏ[Hց\eF y_Dv62ɜ_Y؛6~>mHM ތb__ŷ^skCͥ79u}c&9h["ҾdSvQֵuN?}{uGS\lT\~'qxXL;xYA92JuZ.L|hQF&k+[jh(.ϋ3Ǿkbpm/q&Z$?oe};F.վg9n}\*Ж=іRo߈|T #4ĝB=G"t`B%8e+vZ2WU]DzVy੺h8,-"J,ogO\䲨nigC:fT7 l7bօwEdjl֧p._Hs/A8* 5iY`*o"O~i:R- &KV+=\( Ɛ. tGwk{=[HMGM;IFѡ\WMB{:TjR UToPv}[sSZy8ԗ$ tiqI۫GP3P͏b>wnRgNvNDHoЉXh]A:vLWr('e(ap'IyP,(SI>l0ˏ뿬Z x[R䰵 W\?۫H_83k 5[_HƯL8hqH?6@\նl_>F{do}GEZms6_a;'+ %ˎD4Lf^}@$&r[DI$EvA@}+*0MMF{ߑ(ezQ=åX GJ̳D(f@{=p'AW?ـ}qq EcOLj1A|1i~uߐw({N̔x8I5\QEh8 .-15TD5Sw5C3s0!א@Ru!t}; $3!13f9e*(p >a#փϴIgN㘨G0#BYb?TCP8=o?t/RQ|#! qf|oI>E,gamwi5!x("4G\ʕ.}%7$MVw"s(s5Zl{_e`,kg&L:KxuIr!p(:20"bbut;~m,jYiaRJ22f=W/\npvJk=v 6>֮TU@U…^^֖Fw69gg vWVڴ=h M*ZXe; ]+S=nl3mf#V'ڊ֍&bgW:ko+mGk [T̺u;K0?fI'Hw4aT&tTދHЮrV-&E#nzjgW{4 02}BYvư$Y2Nl{#3{' 4xَY6Xw_K~~X,vMhqkWFH._w#16uf[.A(n ƞ#s>cJ3VYjJR6WSoG-J4F~}r&-kjK_~ǶnRCrF#ecn,:+7A#R2S׫\I$+RxR4SZoapy ^ءt]®~4FRN7h3;4aSC,)*"[/3`dFUf.`[k.jVr;Oi @G"mvFZ)Vju(Nȡ渫!ʜ2-nJOH?;:F?nJ!0ߞ_]ޖn“6]u+_ :ت+vb7ęC*/~Aw_/RX7o,OM#7أ1lFMڈntF'+|_%yW hs'!/Fχ zŽH"V |8W-<2z{6=~wUW\]E]aOfN(i0#t0Էx{\~W08qE,N/=Զ6t{ҾC[hcZvLE3(' Vn@}+FKbBH4WYZc.] 6妄=sfΜgm\$Wp4 sbM*rJuxR Xth(=?́rn0K`{h=FHskRs?_~\*m7Z)xŌ-lQF`U8@8~ocyX6 E0>_%hzwR 4l9/iƄz>M "Oco IV5qȚ 2$I}%έ"$1U5 26]IA J J"5uҹ2)NT)ԧk rcYY6[v$̜tU% \8e~7=?T8ݰ~.p]7Z#/".!ܗA\(62pL;V+`WqT,sea)9\Tyy[̩ϦgӪ$8nDhPj>uzaM]ºdYu:Mj SrU4DݧtMA=48d[I>[6G ma9ISI1v .u}"|i Z}b_ 'y%k_Joy&)6_~8*ڗlVznӬ_ Yo۶S6`,knm7` J,6(Tߢ=$EYGIsy}k]& MFS>!:-B:|sٔf~srZEE;@e]ՈqC"WA'v%~/S&l7~0t-3b ~D@_US hˬΐcHQ+B2MaT胒Si7Bm6J&m O[X'gVBΏ=)Hdi\DGC0nA7A<7[@5MVڑ* u.0zaKhˊ1WlQxA:$Y@5u2lho? `r֝Li-?<҈p A_P] p-t6\R1}9V/0N;u hp 4laM1= ө~aW> / j]\Oє@ܤP&5r::c[D =`J8MnU(W9hRĺ$)c ϴktqmy:~'o:W ΢iP2+_i48VeRfg785ူm~-Ek5f _徚O7wB!ƐsZ:S3qN.//7+-$zyFΑ-t<̶4 ѪĶz&$| h;d|y~wZ;[( Htn?}#Ț|[w!-ˤB(1"ŢAg-a˝ecϲ}(g*.4[3 z IνP_5t8k?G9 dHŌØ cC3G({9 H ѣLQJ;Ru-Lx/!Y}>5j~[j3-pg{ѵeUnv:EhUX/#\JH|vCQdyh *CH$k2{νMX> ]8W=@!uH?R-){kK4B?p5G#~WFqii2YN USMyK ܪ:n ր3<5[Jkڸ,`2AD]jm~-k g۩ikVښU9+StQb atSYAa%TSmm>S&c5r}&2VM \f 0|G:!B̙ A!I >ޑP_z!՝[w_w[m2`Edm @o/[^L85tc3֑aTs ʒ?7[gaP ?;78~ /*3 ~[CUqno; D Ͽ>(K? ;ݘ_VCv\g͡/R)Btv;nr/vȃzDzfl;fX޸f˱)X|qxI[Wϒ'anY#aȰ-n?QOGj.Fjl݀S~n]xmU9& =s:r˴T"Nv9s]glH E$e}$ws.3%b/@{{\=Ta_].8;ZT2[/YU֜s,J8Kʔ3x[)=8ޭntC?l s2@dK\\Tr[}rHsAF- E+j+3̇9(BWRtM@ՕvD1jnj='՚ ׌'񚴮,1NMtl{Mi1/ߗ{*mUtKM|e 1 AÐ:`9f$9 #8}p &#qDOdJq/{| |~  +0 2f4Fc‽fYfh>B}f+ՎZHDA+vH}V}$zdQ 7 'HCῊR/OĪ)o,'O0f²0k{9>U8l* B hȆRPoSF嚂&zA`e( AˁևH6O`3dpBÝ}X[27^d,A=J8 z6sa|kYU7ݗSAڛm $]-4Ma TF%~'PETYG4J8ՈF%EA;/V?"LM3`'2Y(P2V09xTٌ֡pJT{7]abkoe k+E˘-^(/8AH0Ph`>_cVM7q-Ȏ8"rBb =iO 7 05>V='UUVf⎑[Si"us@ qyP 218XZI 5iM)NVz(cm;e9 To^KP#4i[*|b聟v ;5Z wbq騔+ aR)ʊ5V2S^| ~?`pC_e&j]IxNr>6Q.,gMFe0Fb9Ew.lsjuB[&EogkNV׀ %I<yo`SeV.l*_{ 9I)SŪ:'=~י4FO. q+ Je|QP#O0`K_ta깋t{B_+XӦ 7#Ed=S31*/=H$>_pYHqqY$(vjV9,JPqxىI:cTk{bJ}m-P[voPS=#~vJg`Yu$-߳~jo##s>ۛ7g/oNف-2aG>>-k_Hq$x?۫`7pm(ٗj\Q>J իM=@Z킔ږa}~=j4j */e Wl^TR%1AZq8DyfRsL8UE0$c8\e!Kk;瞐U|[=rLז`•830RPǏ2,`|Lb!CQ"l] jюu:E`Wb7 Y>4 %ZN.Qל@0@䵝,kEI v[ū"BZfd0ͣj(TxAtc;dƅ#/oSتJyë7* jocSff?y { Xq~B-b{2Lh FE'*J.b;r,.NȞ)[|A`=]vz oOn^<{J4G*)ؔ .} Y׎YV} {ԷX_O meMwWcj" .mUWƲmԬiBh"YEMD*Ɲ(z tHE- l]`]D7D84*%*oD&Cy-/c爞x%{z.ڔiNs%VUvl8#o}oI^׮kխ(ٮFw?*&5e2dTpM^]i lpXnFU/mN^n}Q |MD0NOݐ{-daEQ\*`?>4YS%7"i%#fgi,8fuI| =±fᝂ)R9/_T:ި"8>F1Tu:?YMq֬ܚvvO~S^__7Ȉ D[>VѢVfџm9Kt@lFJ4%3gd){"ڲ 3:dnb(]Ӥ֢hlSwCmxݕn*bWo3tsŀ:2jB"<z=Tc]OڋutξdܬunM װzg9wЖ.[Cʣvzs܈%j.ᲥKX>L mzj$3qÕq H0P m$87W- Nn:Crۧ:#,(MS)Z jw.-oj1rw`fqEFG9)79' %ʢo ǫ>M;ot>}1+7n B`lWw72l㼋 QPFl'ַ:-^JfbِXqv,Tnܪ_k|Qt^}g={I=Pkvɶ<1O,`IEehY`U}a_oHiV.WrڊY+߫|5* 2{XO+ڲoCꐗipNI±ۧ*퉦G$o|]R۴b6$^7'yZUbn;ؐИu?S&38xsUszUUMvs0/͠t̶R5.#f J-1 y6jh7^YM?$84;iMs.K(@P}J5Hi`OFzD4b 1{Ro3dymb.{-q~;Q_S%<#6R|Sk 0hk ̪Izʱ qAOh:'i ݛ:ZHh(z h]B zxWno X<*uA)yQ$XoJZl:uw (1XYHAZYu/ck"DF/LJǂq+E ̸6muvhwLݽMQϻ% I 0r=ˊ<+,/O؄=+{67R:f`ex-y`_~Pnj ˙߱e X %2JM!һx56A UOի 8)o͉ rH|}{9ųz@R {9s/PTX~gUo W%Fx5|1Kc({w|T+] RQ<yu-jumw8{AVs? 6꼳c[5wYoƤEl}CQ ֯hi󛙏#9S:Zߵ~ã:p>Y[u`RbpW|[K? GQAT;Grz Network errors

Network errors

// 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. cr.define('errorCodes', function() { 'use strict'; /** * Generate the page content. * @param {Array.} errorCodes Error codes array consisting of a * numerical error ID and error code string. */ function listErrorCodes(errorCodes) { var errorPageUrl = 'chrome://network-error/'; var errorCodesList = document.createElement('ul'); for (var i = 0; i < errorCodes.length; i++) { var listEl = document.createElement('li'); var errorCodeLinkEl = document.createElement('a'); errorCodeLinkEl.href = errorPageUrl + errorCodes[i].errorId; errorCodeLinkEl.textContent = errorCodes[i].errorCode + ' (' + errorCodes[i].errorId + ')'; listEl.appendChild(errorCodeLinkEl); errorCodesList.appendChild(listEl); } $('pages').appendChild(errorCodesList); } function initialize() { var xhr = new XMLHttpRequest(); xhr.open('GET', 'network-error-data.json'); xhr.addEventListener('load', function(e) { if (xhr.status === 200) { try { var data = JSON.parse(xhr.responseText); listErrorCodes(data['errorCodes']); } catch (e) { $('pages').innerText = 'Could not parse the error codes data. ' + 'Try reloading the page.'; } } }); xhr.send(); } return { initialize: initialize }; }); document.addEventListener('DOMContentLoaded', errorCodes.initialize);/* 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 { margin: 10px 10px 30px; } ul { line-height: 1.7em; padding-left: 15px; } a { word-break: break-word; }Yn8}WLi'Mq t{YdQd>HKLhR );ޢCI%YE`S\ gOUh N`~HJD̹G,B˕<DFJI<'c ^j/{alZMXy_oJST* #fi&{zoO3B]D\#e*V cbfg-@ׂ}_ [6)hlsXHhW )k3YčInJU*5l%o0cLqH&daM2$TxW2F`Nn*GTf17 ij3+1O)qk5 7/r$E2E^~~A:t2+Od{ܕLl''rb aNMyb+8W[/jNH@Q F i\D+x ؇X<8ht lEe'6m# Q6N}3mV * j2=`yܚm{ItV)`j\l _6JUl,j~11|$+: #h{4 ,mm0 埇3.)/ݳ0͎g6)O|Ͳ4Ґa\{_̴-ra3{mJxOU䖃[ooM$:%U&^֪ŕP-!%b,0T F7b'wp0gE٭o?J%tZQs6~ׯ؛Xar>WvIN\M"!6Ўw$Hs7IF"],9y&UËR TmVnT)2<C$API=M^RkPi&ARbP V; 3v9<)C61aZҚ,8W/W7WrM& 6 x⎊` *,<DR,VcԄFF$ I՛B6T!`o&CUC]ZLȫo=Qf$LA9|C*!l8EY(Gx$=6fYBnB\#= p(V!lr%~/s䧟Tz%kJ*v74w+ B z!S߭B# 8ïԂJ4k1UlD)S-@KB̾BdĚy=Dʁo^AcT=ALnbssg.kIS/v QI< ;D}qK˼Rê30ɅH*D];pQƝ%2ϫNhO>ƫgt<_NEcEX\nc/s`e'/4tns,Z¥CSFma3FF05!Y ZY @sP뽞:P3tʴjۣ^~.ă쭕CW|ݒlƉ0XHZ6N4 f~=H+J;YWV@wb- l):coHߵN$'դsd1bxG$ǀ AS [} $Ƚ/e[,_gh19Y:[x~{'Z^؆䥮F)9` I7*/GV_0bXQs lf:d:Ji%MgR/yvOVƕǤ9\ΫQme,Ũ'UY]֊xL] }/3\`.k! ~4]DᏒ*'.i{>" /lQevz;E^ކw(&-aN2~"iW^u q=xO^ӭ[#rSNWj /"1tt_Dn6!lMK_W|?ڶTl1y 15bU}Bw#xXih}eAuЅ3^CƂ5NSXJ-CzW`vx<˖{kC?Շk+GJFS :+wu&$ٖ ڜ1IOki 8n=)ܼN|UɶU>5Y<9(t `͎Z^+gaQK7zϏ'm*DӪ[ضZlвbxhm>um+Bw |bMo+MoYuԼuT1=mnf]01=3up{Vyo~q_ޘʘXKL>y/.mnkzZFsk; ˭~#۞ۖc;H:ţ.'7Mcx͢ 0_=FIJ>\l#mR0#Ң K,%{=Tz{X8vwB>D˛fq [rU!KY#lkOnxicM>8x f¯@%Zz I!=z |y%Z. {Z@[۾x{Rq2\y CiB* $ |U|,7&y\;rF;]==@YJF6[T&HMOT3RV9,HI6&sڨɟQtf/]3t(hD{'!z3GK'ے /g:8,R:8#fŤ0$w7UL03Nsdqu9$8!^{]k 73^K^J:?G yWL:(⊝ɘ=ZKc.u_QHV[o6~8-;HȎ.+E{(HbC*I۱C`!\yEZ5B*-jveؚX*4ʚHǫD.ゖc7!gW(q'f_Grw2>li==[[o*k' V"ܖV]Ji dG9RyqAX@vMwl ~;k@) #|x+8FI蘰tbj Y[Wc \Je lE}KTM-y@m W]Ǣ-)hC$(./DxUΕnDw& ں~pv(`eڊ2]__/N#ՅFNpB a>fM~_Tc\9('<}hv{a(ONv"ڽ "a2nƢf\HiOxq\: 8e~w%̬s^)MS#q# xnV Y!5)0YK%%|,JOj`gdZCQX_tO$qy`ALR> jsfBs0ZG ůW.y.[7RYXOiv,>=~ۤrڵM +n"dLSjm~'xEVr /5| /×2AɛҳٳdZwNWLR}WI}F-x݁av`PPmI*؁#?"$vܻymT_C֪;WTgڻ-q>Y}[@FV̪5TqۼRU1+iM5+'YTޜV(S|M60 !]I <uפTNN.^F-zVUpr]O&MVzYVI6bi&uv\:t6YRڋjWZ|@婪 GlX@X)t1ʫ&a6H eU]YH3D@-R)j8J 1oFTCW;*aVab׳ =V¾>r6DJFQ(K̿v"6iD|RW@~\C~;w4R٘pTVr: BFtZuQ?L3 WIn ſTᬞo[u0iZ哼G%w`OeX hOnp$>O+5E Y nY&O ~8 GUK/R0BFQ#dR_JX~ÿ a_R?~MFGIk*@I.`VeoGERlEފtVv \EP7鿃8*(;%FeS^{E#yԳd5YjL.o<=TkDqrW @rf A%[VuPuBp0)H-1(avy5 &@:/%Hk$Hd6 (*?enshzN=g Sʹa!~+ݷ~V0 @aEM88_ `H$Z1 ZQ.~nV6Qxf~> #˽Sla}%hk>zso񏮊A~";T@k>htO&dJS'VӮrL2#h_/;ONN I.E^ $u&tsF:Sg K<-Nc@BgR%o`}u}nbd[]Yd4_Z MXϷ[5͆SZSy.Ӷ7]̱+LBaB Ӂ6 ۩ɤc)6[!!=[9^:A(!y0{|xv ({(C W~#&Y A1| n*D>-AOmTH6N!9R.mwذ*:1 Wg?4V̎)bL(kX$rDa󒚆 x1ܚG+b`O>)1~м{~+|RhnĚn+X5蝯_1O \'/26W7nrm\[@6\u[nE{D]e qDGd4h?DfANp"Yebiޜ^7!2!f-nUNf!@",졝0RZkURYB EĊFB3n)Mբh߻sھ`Յ(܀kZm֞if5WYdw]N^˗H #7GLe2-N!䖦mtYU!F|S!lo]~lO7gǘLXnZabE@zXs 3мܕ)`$X<v^HضU$&MzrwڦbgƈAQnϟn9Cr'*,0$^*c&3(g|<Bq 2ƴ=wdzo5 |~s 51=Gџ 3jK|}?L-o;S5Z&;irÙH!%Ru|W6:1f51HS @;|O^ebx"3N?" f=Il6L2v80 c d/G\c~nJlן_Gwi]fVǒuMGD@oh9:ے aa;O/.5*&WR'i4cZ#*}qw^?`晦#;ħ-p c^Dݩ L"aUd&.)'K򘧇r&dUN֏Zh$gvycCz_4*5/ly -l>tv_exLn2 E0@` Ya0-3LO;T#p5OFMTU;SvS^bGi ғI6 _~q!ӈ~^,Wp{iˊ =R0+'A`)d (U 18t 6\WX(,S1=$#N(^CPjZ`ը6,v٠g؈wFQA̪ v1jzZ@٧DŨ힝tvpyzqdQ[;8evBS f((hӬdtO}{xp/P=9xwWBX ^l`;&+(%Fr]:~<9x{WN?.7;qݞWĶx1LXo/74(ը~X\Isj**>~qs#vh'5t3m FU6ڼFb$ BTee+t;6K8򠚎ۺz|-Vm-dN98.mX&;9Ce'][dSҽŕHFԶ򻪲j|@D5V7F}vs;o?Ib!RC||x'v-8zq FDXj+~p;RL,m]hmV"ZH˫ڜ7d )?< K>?1몳y#dw(M[ә1rc3}fq Lq9-ĺ&GF4z+(5Mk/r7ܞ"ެ[$k(n'vk]'dM~An&E$o㑨oq;BRJ˦wC9@>s3=QjR4.惘)<?h@ F*qht7m詢(F9")ʙ!phU:G#} n mu  W@ߟ$k5ǝ5l lR0U#}`YUQslG;ʀho>+fVc"H7c0a rigW꺘Os>NMf'$Cҁmb펝:9Y8@˄b O U)@b{("UVtF[}s#@lzj 7S:@`XVƧtC]Gw?\mv%F(:NƎyC>>9g^B1, 1˫ яvG(d:؄DA |P*+Z465.0ZF- CXUI%_.>;N +)z&^Wov۷o;.{ x{8[O+ ZdRtƬDj,οM*G }F+~O %pa/2N oyX`77 4n`{Ai9ΒjVr- &%g`_eY d\Tl\9 GØ <)z:!>t ˘3}K@ĺNpyg3+Ǵ="%#^SfQiA|HO͛b.y"9XOwc?od*(#'010˕x>קNj[+"QGEAUgg7㳃A_eTFN$*h䉤\W]8D_?V,uI)Ƀg;ߜ' %Q 7I5; _i r-*iĐ@V2OiіX ):Yx t.-X'Ut2yXyL @UO$Ox- ["AS: 0 ]>jKm aW'%T( / n$uEe'@\Ղ~^ja01ږ5[s!%8$>gkCOq(^@oOco{2b,x$,| }HBS' 娡ɆN~PCxhz;D!Ȃp @ Y&&aYv e}rm!o.;3 OvHFVۜ ng mF7Xx՜ȪIlP"@;z|͜2d M r8 ]Ԓ o'&X'O=?'_@N2X&4j3E]atqW`rs u93j|Q6#bhro[K.%lb!Tm>S#Ҥ6^em֚Eo O:{:QEyud(sZž?sK! ǴkE =Xvzzx!t9<1Wkr 7  ք1uw| +7|W\/[B6zDu5/7%*2dJC>2auLk./.,m y2~Iv"6s/ j~Os3$]? ~sw-ǐtB?o9Yx'Z I2 #_`/ɥiTgyG>Np^P5cp5xocϫeҺ&ţԐdhWwqzx/(Ԇ8P(064k E9ViYy-٨ΩHlxo&ˉXf S\O[e yTu:,;[fqmm--)}fX7Z :Rʔۥ1:k-Rf撙mW xxzg6+ψ\})vF>sH GT oo۠HX+~P8+ӣFLZxAbxqI,vY<<1?UDR;Έ7jѢzQP Ӥ n3y<}Ţ>\(bn}v'6i͔?[0ǵ" B-cH+[$pM *KyUW|,j_AsHLH:ja3bNRK'hCJ-h#:k fJy gs1P==$/ț$ ]?nx)3?EJ}҇&ɊK(l$:yJQ&*wQXp䱹@]sp 3BybtA@z&z>W'<#Ko!,DOh_%I7W}8! jVa\cx)^("Np -VdrC7{U_ t2Vp"_ :&-Zff,B칤03GZW͙`T4qIe?#T*^Z|0֜FM qcՎVfC 奐k+KWɄ I_O:H2"rqj N$1el-5(,IBTy͟O/O-XG =+q8xuHD6P-bP&hdXcS2˛u'bU5QFn#7(mkb?3`(sp)McO>75 |xO&,dp0uV+߀Uk4w?;!1Z{ kQ!Sr*3x))a$pFCARz:, det =-k#;z„>SW/^6#S>^#Tń. I()uvHg|XkVk͋]2iˬ Y| !#6üsQ,Fy!0^-k"k(RO yLzKRцmBʜ"Q^(㘉Q(,6S-U+XEx g1||WUL(-hgo:m*AY>K[d3C.~+[5;Xd6K {7 tiUNo'Ķu9& &=bչk(6LhV$(gaҞzyjwx[K*sr%̙R72}:E|܇hop7v.&&ƌ!#;u ĕ :GQ\ oȤ:wt{BbLi,"[X' 9P!۝}R ƚhfRP҄rLAZIYRrW1SA ^ [1PZ⨦B髫bTHS] Q!HqX>SV[w!8QolKBѱ Q%_<Ҽ.DmyVa3&emCm6l+31%*fQK.yXXbqE[T'`=mb4Yt乁.4DqiW[g^r_PzLNML*0Lr!&q7O%>[7ŠǞ 7aBMItHSރ}uRw'~6j}л{98S/wON?2:;|T%!M ta!? 11;Ww^1/|+l@v& $}ߒTty"pS> u ^QVLg%@]ڒr߈IFٶn-imwSf>1=T|,K\SZTΝOƘQCBJ?:\iLϊYyJOq؞R=0'JKno)-(j$VΚ99[s󁼺 nw=iƘ^, E}CPԤ*&N"[v,u]Qݼ$#I]M ŷ"' vw"B=Aq>I^CSU )]T~W.8W\9?L[ܲ{A?!||T?xW_EVcs. 0|D8Pހa /Ϯ݂0ZZ|u{$΂k$c0s'"r9%zdp2QRuV1ŌiK~dn,~BHˇNYAB)4֦ddz`KUc]žhLsXuΚTm跠)eNs&b}g| 2t[~cP@ /tѺ Q"zn_GsɅ-Evh0 ,Q4 SE3E.P_J _.c5 v9GdIByqmeThˡ˶`tz|.YTO`;dM3ܱŅ(8T1b~j"c;w8^vG6ّ8u+s,S[ȖqFGXBNĮeМi>IwuKm[F=PփM5¤"Mc_/X).P1cm2ceKu*,kma=L*c6`~wY*̦6j?[@E>uuݗTkKyXQaJ16fVJD [ߌL%Yz$9s%@^^|n p*+slt9̾ف#) n,3Ib@toΠ٫}>nb$뷨Q`ਹ$||Ixo˻0z'':4WS6ci{W^[?m8 -SdEfroޅ" >ʿP\|]= ??S_?E3ޫ?՟OC  B/payQ S%")sC )lTɿsRqf[`ŝ"Ԭ:O)|k.*ՠ<29mq҃mDRQCտP'Uk#P5n;*nwVt>Sh+-/yrD]YϷDpk%n?"No-i]Ϸ+m0|+n/n9> +f;zHv7Y(R%XYGSy&Ifi^t05q` `E?@& Ԁ98a "l<}ڠĨD%lکE^ZubcK^-:$ cˤQ8I }٪nY=Glԡ>W7d1'Rz1:(92|OnM]|:R*_[y51> $h˫L:3ϳ Av ي]؍ī`^&bWe(ŹL}!qPsTM/SuY1-SeQ^Jx$k76ȏ9 e9sCaoZoˠɨ27Om9|0gD㴑79Xg%UfqR>켮dG?SiAA)D rvNCNnLG?z tUo띞ؼY:Nh9`Pp%@Iϼ!2d /. ?\:VzS[SuvFjni*{T'~7*r7q.m62.\+6-6W5ܳBK u 62\L>6F.RW_JiƯ򹅿?,!EU(IlQ:8k&,5Sy-Bj絮o_Xл5ami+$䞉W7}*cL7>_Kü$뙫笻 .X|^l LBEg2d(B?w\5q*A^BzbFC<)Z+܌ǞV]^rkҁoRbV}KwrP}?ёWؽ K]_&te(A;Q>B6eZkJ o=;ۊ2@S_CfNA nXw]S=p:\Rp3ǙEsO(=.ì pO3;qùItylF]{i8~\2BH1^g^aR.A»u!7* ҥBAVڹn+cӜE-Od^L` Q+OmG2\z\ ~RݾuCyޟ7[@,=q^2\6-jr[m g^;[ۯv^շr- F##"qctX(W.\Q mEBR8IЌ(7l1SCW$A.W:W^ӿ%_zۖUC1pPtA(ڕ =mEFUG묪P$/"7;UM]׾eD #=]up\z4\)Py=-`j4}uIE5p cTͦ|1t/ȪCgId ;W$CDǓ0.qWM%3=EnOFɨ=l1>ҫʬfyFo'i3ؾ0>7$G=$.:Xq駷o*`H`rA~N]"%P׮fWLth%6 r5[1Qq4h Tw3qyMU!g[v[rǣXĔhVĥPANXrn4}qVydut_#80#gC]"!)$m^_{kQDk V>QجX#mq5s`*5e vg ~JluNs>PMϸpimT̈́jD+&[>F(cLn|on̛o7>v#u19͙#S.v8*cs3@ p"~{[M9G4eRAm_ʹב0L-}t7gG&+h kWSbv%i+^+X#$[A:ңOZ7uo~cZ|_y;X6G#6B9zMu&:|k{KlMK#h;0{E-ks ?FC"nV+:\2'/BהQ.x0]`$|WP/ʵ,bm۠wHWvE:zk/3%sub)i,⁀IQ;=.s^Kv3Z~j Nm4On&c\#Z4i؍ŪARb" \<\J_tޑ>`9uXU;=Rf|!"xX~ Ru *Zlpq>,-9{"4Jd>3Mbx&)/MAԢl6"5͈X?lϼզ34P:h77C;4 "Eu +)y Hkp^bi앍`U]Ov`5,|akB([RV &"vqfS.[^/q Rƃ1H%D4pi;]5d$&3K$@h\Q(]pğp*G[+4awxEj٤)災W(%Y ʼM o6Ul/ʞAc6NUB؉O7|NDCVϋ o>ģR˯dcկHR]0@ut]I*&x¢@ܠeGchր[U pYB3Dևdz7R;dY Symxm.,AI:=<81G,&ƥUo?鍔s{ErV~TO ͊%IJ|sC!cX$UVǩE8Y'uI1 IA[eԇ,)N_'jlrXޱo,Ow9Fd3bZ>~@ p"!@EGF/LS-met`ğ|Ow6Qq'x6*"^RoD|woޮI5!03OM'os/W:Y S ^̯bT}],:7Fxy5.;O^ 3$iq7:n'^w%=9X.x-1^&:wUY/MEŞ}}_wDZg_'`ұ(m$*:ΛeX*%-t\+g{[ĤӀ򸡒 gc8޸o} } ""yЮ'7~>skQw).vźIQL}aQ4nG[g6xrTO߄Zچu H99A@=&I{ů2X@(ZE=S3T僽Wq ! ɨ[2S.2waR6Ӭ}# :wiWt@݃+<˥iVQٳ\UG$]Wrw[hd(}!Gc@bw4".Ng6]wm6ˑ-tykV\X2DlPH+2)b6Vk'H2%Y$Gn+kaJ%;?2+;եҜ0"QU2V<`rǩ)s7Fsi8HFƈl6YX=MãS,sg>r8Ysc K[-~]rCMxk 2[h")~Xh"(v[<+>u**Jƭl&@$ F\Ozp4.) ??lw_-y9Ӫ MwiDp>-^=#`TogU1rgduenH)- 7ғ ᯸@O" HƩkhzg붒?s#l /`k)Qr 곹SdҁuPg{0)b-ƔJ\2(LeN6|@XR"&豠+COE.E ɬm ّr}M"J~aS­ռ9v i¾iXش's/o2~;5VztU/EjPzJlRT*;ZA-+)Aإd)4U,d72贁GD)*餮߸']"_g //xW5W/zEWFW@ 0ՅGc>8q-G=a&W]yvr󎓩/1FVO髍^XKcdʖ0l4]xu49LAnQA`y»ZWUik KAr5~ޅrI)L([%<̂/A%}e/3|4:X~sݜwW}tV݆?u?\??s=3WhZ_mCwɷYc? ^Jm:%ٝ]\+06Ls]"=xX&qYIu@D<v\ k"0G0dxC+ tKJ8.I-g-S[e%#+[&:}ͻP%{r -],ik\^-Z&nA5>ݼ6K"Ya,V(AxK{.73[ە5Þ[Wōo8CQ۳|:jv(%q8]< .)XwIK$`T>;cSB2DB=Q5X]fsYc;y#nOY8ޖd8+KU6(RqjP]q?ala@qVwl r6:6'i@kٖH ^_5* H@R̮VX؈Nc?s<;rdܱ. eUu+#Af-rB0e˟>G 1M*),&7_1EU䔔9M-m-*&A'XbZ78WSۈ)tU>"ӌ>#s'kmr I9])ۈa‰_@M(8@?y_5eA'?=S c^dT@cJTXn1FC 5Ӳ(>& Z1R:L-C~s_lI3Z 1/臄3/Ŋ9臗'ϟ 4@^slv"k f~@G^e,Z2%Q>&U?R,3~TOT AxUykrꢤ{ vK. :! v\-^p41h\E<|̪rJVYKrNYbjTsù$qSH `E ;߂v/>DW(chJEU{Gt\%Kq%D%e:/a8s5s { ϝ"4MM j M)緿Ϩ7AdYNKVĿ&d0}Q.qZSъ3^'@+ٗkލWR δ2z=p/ 1].!;J~-kv3T]䌔 q7o {_e3'9#{jv2Ai;_ͺ $F2ù# Nhe|̤ܵ!8=1OK*Ӝ>>;2jtÑ,g0-*LKҧ ܤ99~*=x3<=λݹϴ%@diB?_Ɍ0_0/UFyЧ}bM y]}C#; ԓ8>A!~+pXf3A/B +ccϨ֓bp~"4+ZF̛zrn=ʱ=wĘ:tHhFkc_&=({QINҼyrK2AgF3QQ_rnw/# _>%&x2uydq@ SI)q r6擧+кqڊ߰_)9pD{:Yρ!qjj'< @B)= $OeBD0P{yz9@wK-Mx^|—[OqUnz`k cD%K^V "4<<y_>#Ã_url:n96@hhy~ŕٽؑd)(VNyNzs,G`8sN:O 'Id6>J~دSY 6Vo%Lͥ ѡxELH&!: WM<" vh-ޣ;- 9/Ϟ~ hr f(y] A9-]HnԤˁ1P0;8b=bW^!KOcl 5 `{ű 'I^j) h<QĝuxPR*c">c<=j%]8EDv| _p3eϞ][vO~ NND&/E6bxКYs>>H_7; lHaqQS@4cE_x:g6RPzp;kh3S JlMNӼit]0*.`8ߐ=DzgϾ섆 Ϡ@{އ+;Tm? qڻQp , ·LKi>&њߊ<>-6)8Y<I Su QgfÀS9u+d B^y8ߧ}³O==%;ԃF?]AH!Y0v$8,8 %x6Ae]doW} ./' @34KOa.;s0(PS ]H \4=3zik ciy80/C` )Lg7@/`u\upydd?lWCGT4DC9ۓr 8],8B2<}7׿ h!'}J@ s2a P9(YH9gzQt|_+FAang|2 sQ`׈^/0Ł<긏\gx]yW&3_ޕas_+T A{& o'Ic NwםICt}s>&UwnT6 Ip l#K><JD ;Kj0 ]S”6U=u.LkQv}zWl'1Nl5&tF3`q(K[O(ЃNqP87>wVӗ` Q^&'‚1+h:Vc2fڍ@9j?'Fr#9ЖJLdjg+N#'x Y7rsH9v߿b/?lιz!}@N1_/ ooxPNCNBG~(>4ŸN|8* rfx;SKUh`|{i^>}qX T\$SL9F@,`:G.f<frq:>'jN_J=v/{*av6u"_ק-ȺGH_z0>{h\ wsD>K\r'bnKQr~K='}Ee,u@cߌͶDκ;>hw$"Fzsry+T«}>iQ8oVpgޚL^}U$^5yųw EQrVVɛCKRrK2qMn^rAy2øE"3S,9|/q:xd Vg9kďYTWδã37~NÙ>!< {ы5 ~9`̤=hgBUEux t4b )s}/bR! G% 8yσ?ht)C`R;DTW: u<(%Ί й 3U\25N}^FIu`MӁk;'3୯O_jp5H"WRdN_q΁Y~)s?W3tr#y>D/Pl=`J%g.܀gIOǻ@ XT6\}Gh_f3<ˢwpQ^ҿ Hqw8[#F|5;׋~R_nP~O4ŧvuCWYwgt-js#@_ie?_xk]l547]\37:҆oCM"xLe~۽x˘ѐG's(pZ }]Cz(NoWڸ'zIٛ_Kꍓa,\qj~c*) &ݶ Lz_GNj!u 3?IqSﭲ*@¤].<*{u]Qh$`>q63J8+AC/6 }۠ xy!s=\BKihE<1#|C"Q^FI;pcDqcQ ҥ>?7뵯ygyЭp/Wj|a$\/(tUqP%ʋxNbƈ{/RGs6ө$P++q/uj# .|H'aP ix~"'v[Cк)AKG抪C 7D<"M?rc֭}P+ycMRX}e]2k[Djʏ%6a&P|7DC. bx[3F2OjF>Pg'~xs*F~Hi!WbW|a~H/κpSsğɇi:I~gt5NtpQ6d&̵={zW6p%*њ(ď=C {ī=/=)ާq_C\rq\uysЗaO,Pr (Ʌ 7HZXb/is+?Up}l|Ox:uWiݽU')r *2iIc Otnn&T5xv=z>4KT?ݯ>zV5~,͊A7 K`Z&B?Et4'h>H;| Ing``<{%u~f4yࣛ]_P%m)%̈־Wq,k ~M CݼIyFL$>1jI_k+<)؁2Ь=#jw-ʹk+F 4F}:f!:3JpExîOg9&' Bo9"ם%@83%2&Ё".s?:MY}9e3I;ءJ̓:ñP* ]:G(:_/Wu>/lD0/>ʠnq_/f:8&`"}!gmeVd.yu,cq)7ǰ);(#@k3y;% +Qp<(Lj"QŖ$9Wcy1mkeZe}mY$9.eK%ɖ|1󰴥ޚ1i|Xlalj I*}\\%d9n45RЕjJ=ieD(AQi\X~Pag}k`Ҫem[Ta.#CsIgJ MN)mKVr=vXf%֪>f$6%T-KCBKD\iGu,Cv_&9QҡSH-?uX";;B63mҮ/Eu_Cv0dV'+B+FzhUB\TڜIg l.5y!AP[+YK:fk._$WϊX~[gR(hNejeɭr]ѹ^BkӨ YNЎ ίz S1o7K. r;-ܨB6 SZsLժiXob6iܲ2_tsml0U(cNmqT%F`n"|P%mY;9( !l{=O "Y$7>ƺ!Al6KIkXT~?qvZv#J{0acK!i6k>vv)|},Uuf‘9m)T쐰u6DWnͷ- !ݙM:0NȭЩ!imTifZ驚n -IRs꜠Hr.-meҮv3Xߐ;3W9zhP=mԎl;JqC+69!#&#O%F! Ky W +z8WŚBg[_f&%)5f4c+cYLrr{[rڵK>US#Wۂz%o7f9E+SڲdmKκDOK"1X[(W AֵZ~^\n(%*8++G-]\7xRc.[L&W<[d5;-kK+2$;hh/GCChWVrSXux}0KS)~ `t8Iiȴ$[ˑzSj24S--*$ZL欜E3]hkCo7` C7rSNMEy0l ^|LbiPl ,^HɬϔFJX,gf%sL0uV, eNEsfׅ/dARIT2{a Qq1ɵfV\#ZftĚb++cC|%!Zo SN }Fd]mۗh}1ymէvI~w@'R>(HcU|\u&]UŖ{-ݪ.wI˲Qj;l~2vKw]Djb2ђ|: KیA Hp]ڸUYI4=5p bT'^>,9[dJm h\+ӥL^icw[%Kg[SCrZXBۺ1cBsݔ 2^TՐLƙLeW+Cpo YJl.1 fnD9?yP(Ch L"V9=>6*)Nv8w\=P;FA&Fsu;!ꌹJ zf5Z{6[4^-y溨/9zGՊ&Oy"qsG ̆ΈNjWd2ZX}kNhUNt@Ƀ3Tޓ}|Xܠ\SRl>۠)jCR,Q59teMAUɒ<otH*ʓ dU<-T?f :FLpѰ{BW BnY-.KlN.Np\NY].ˊU95J7' 6O U^:;/%CՖ9!U*̨7ިԙ.߭Gđ= + {oE% {P)Eed;,Fk7ejn/7br;d6E\2*S"Q 6S^kwGrXJI:Ruښ-Q3U(3\HN*7rQLOQqXDG/AAO3o2V2jyZnF豸NSbIO|צ{SA8ۻ-ԙL(ml)6NM[ ^GAG_8f,$67,H`Ecσ =r=MSɍ =<_\α\i Z1RÀkG_@F;x;U_~XS]uDql iy:E+OzC%|58.DV5SkqvY1ù >koFk.+}{'"iˏ׃td|gb9#$)=:!%NH겗:;•7wi9^d] —7nk#<1_g9#{vx%ǫ:O>td"x`x_b GioۍO{1ݣ 9,{ngˏR-t/Nc{T`O[R5baKuid^(of-{lAl /̋{.| ;/_| WسgI flȝ.5}DkT STsù>~Gic"vi=^#R#?{𝾂:/˩"ģ$xo`*eP=¥/dhO$l!_jA"EKh7hڐ.h;0J д@{Vxr/6p& q 'ggQ[=\-@Ƌ@c(,%Kg硳y4?+:K'<%x |ĸZ 5jq#Q]X*\Ygs"d$B_xE# e O=`tQ3}ūO@>=*}ֳN>{C睡/-P\_PPppߙsu5Nl>)^3fĖ07R<]F@J̋)pj`=;(Zj b`HPw|) C@<LjPT=|q༚w\A0f^6Ek&P3ΝgF.ޥ@  Ŀ[. ]"Y,O^'+Fp$(cV%we<ǁA*`O.֥%xk{{bӡ<~۾w>,hƒOAuo. FoxBx/]:_3fӮQ̼sW.WkܑR|29ͽ'8l$ł{r<wMwWۓ{g@9_ 7n0wE/ }v ,g쬎^C`[24S G E)a>I^ISrELGp_yWDB{WEutE!12J@%yjT >NieQuC9-wIg&y/W_WѠjYfO_v2EePv^◤!Yy904>Lv8#?(_xC(gpo @^Bq&rܫCxWRdv'p2ʾ" ˭rqTvWEͩs/Ty=.K}C u)S0SpvG-ϲ SM>A07U-x )$Ɲh L|¹- 2?`pv2^t퇆,?vAY<$`tX}oQ]TBQ?`TuZ UwBt2M2!p.&`BQ(:`*G@SGx #$vf 4PS%KjU ǜ{,@! CE:#R4 3,|?0࠽ 7otB0RW+pLt}CGV`LcLyޅ/;D@$XΤE-)Q~ g+G ,>.rܸ%`("ZYQN' ݲ|CӣAaP%.Z(Ȕ/V"&Džjj ۔nG;(?%_}6Li?Nt>z`ak1s;^՝P/]4ETx5,Í6qWS;ZV.5F+E1ʨoR(Gbs0T>t0]z8(^08?ޓ؜o??MM?GDv*\L4% VN4rv龒qE`Yw)ϹL FΝ +$X"c ʂ0-Ť7 9Io￟>}Y&WEF( L*3LSꅐ8Jll$ݿ;]}3LUEv/f3Bt?cY刌@KFD C 8ݝ {i6$G[f,I"<7zצۮ &twӌf۷q߼#W,_ºp ۢa#n=qhA._DpG0Z .!Zz|@%R7M3XKnDN, V*zXRx}p{[7g_И8xB^ ,#@6tS{~eB 9^dLKfw r>:6AqtyK 0|v!ҝsqqк?_; Z^!Z@4̓㓈z?ғoEb&Yk9 ɍ] +FyqVOgmP`.FS4Fq 3ʡ-OLz+cLX6Ғ\au,u+0nZ;%*Li5m`S d LHx f-猡lE09ƙj`dQ0-֢8P.N*j>q7ˮ)fql.1X$1ͦf.#VK̙IMWWl}˷2Q"i(hn()$PP!զ4>@\RmI)B'+H,!AI3#$sTO)=2٫ClD?̇u:^"s~akJ!cf)W,}Mni%%E6Mid%ViO判KlHC4ǣ\SCs.kHHá\k3B%E'b쁰Nk~q.oړ,#[ 7R{E3kvf( :Fs}$^DVS˶9qCfU/FRyWMaeZnG*@:DSdz$mWU6 dTqYFK)]d1Ma s̜M{<12*bZdk"Me[I7X\v=lulu3}GJMOm1ˢ2׀X #LN1!Y@Mڡ*e(-\z-Pv52zcTHS5AHܒf]Զ#Ұ|^0-uq(7q;5fX,672ɜ40i\] U5u^BjsZO} AEz7@oJ@@b3/{r6dkB,kd5J5|b,"m\ų άV f|6ie 2:^Z~GhFrY?O&Yi(aBATrm+5K4muҔL"#ZHd){7.ɮ<յ5L|ai7+ 䁮*zkl8uB/ C4Z6åTe=;EQ^%# IFnH\iQ@DR#WHg(Թ\[P "3Eá9cNDT$̻mzښ sjΪ6z{v)<}erYwk wEm0fz7 *{4cuJy"#`Ok\jp*&7 ~֭$9/J u'Љ6ښ6՘ڶZI!(=z~+$j>0T8te2z^4€ |kbi8[n4mA*аv>[nI]>.䧃|_,򾁌CU/oHϛT[0r- `CAYY;o%Ǚ%> Hq Bk=h5"7b533vsHtUg8tg$'!骗M A& 5vmS 3+δi+@~ڎ&Bt-kLGi룷> I̎ ?ێWq<@s5:-C;~zG]^duM}W~Ge)yp=% Efq#_ex"ҌkOۥ+1|mu ; +!&;QAR(=M6.hl^=uPAiOģGxՔeX&IgM!b\7`4fM*enVL:s]ƾgKMaib,DO8E}l;:nS!O|[{TRﭮmpTi=@ަ~>gܼd'+pݖ|c*$/xF?BK^nŮBm?7Sb r%Yk ˬ (BLw;žoƪm _ YUwAcΒtPIt!<P)^x |- M<# #@[;Bla)7~HՋ|oi{a4u Kվqr_ BΖ„'v0&(onC#4V2 c>Ȓr7>pbDg"Fu##,s@o3Fe_8h@*t y|7B*]Rڱ8 `\]#Jb񹕞rN-qB"֋qVKϪűLQ!m3c^iQbBP}}bb~dV 2D˧u7Uð̳#,Btju&ymG0b匣[6\Yṛo`q>a :$#@8;GPP y V@ ?p/ C{sd/ԗ3EΫrN uu?PUVq;|&]EW3jSQ`3Yg^Gvw /%xюfG| ׫:t'9~kF,o'ܹ#M]Ub:@ @"OZD|ك>"D^4 Ѣol{XP.UǾqKmZLk{0*Qy5 ZS;W\r>q}AH CH*|[BvtџJDh۸-:)e 3T _`|o9vLQh6=*y|ESR7%e`oTZp#IxmMDNX_+?3dk3 0$I/>B>O>d%?F9 2#$ْ{9~&F)rR(#LڛBޭJ3+u_Qq igAVh#x\oų8j!A)9IδQZ=E|th~%| vϡ٣lL#)2ePطs/>r"ڕ*in.@GjRc2$+x}xpn(:2E{@N<̄*OIY;ʑgaN 8_p =+ۑUe{QFÜwL&Y9eg:i\1b~`v-E ]9fowcj852j.EQ}Et1Nk3ic*xыRrP8^~8)m i`2|!)cE0s-+ )l7į S'd3ј{E%Idop&`K&0C0.޺!?6Ӵn ‹phafк̾2TFi~=>>'k4f/OINw$E1M]x|¼>;`FC٭ǡ8EĖYQߪ)e9|a- !Wꈦg6eU>.9I]-,=4'نFX1`2]${, 堾hb4*G̒}׀h|R ?Pp)}f =&@5 :9vj_K?BC$:ʝݕQ@{豴5A#ƍUoo3s62?L ۏU'@-_08{psmm¦ ? .z,v'Jo~s?e#ba۝T=y?l 8Qkn=N;۹4xncuDl zKoIY@hbR SɯF1y 0d=kьm_"TF1fѶhazg$=P̞w3dmxG8^нb;JpD~SƷ2xM0{(+R@SCkko{D.Unפt[Cu9m]l_ʄt\Հ;jZguylih5RwWorKaxKwY{K1@lWSlwM1O kT}&w:}O(6㷍 æA2Fà|mS‚pye-`qЉZ}@& #u\d|:ة }woTb 6,MaZ_R'Yjk%Fq([KMr6]DUs.i-ƛ' |-Z6Rby7,)UDbr䅇dT{I:ӎS]_̞ZZ[D[O&'O05DG j,,ܰЂ[rEWbOAӾ8xk\g?EmHgɢҺd<v![ZOXNQ(n!MYE?WW$$½߆.3;"cG_V<g+=#^I14qM,0]vQ)wF6!D6fH):/\wL[ wcˤ x#te }¥0Fڸ' DnԬj8Tonqivw2'5aE/rfC۔Ο}1im~-U N-R(9#B8`s4+̖6ץDif΂ـȚт#qD<&HC3җDc=N4}j)\E\'yUMW:}js&#II6/%ŴԽ3.VGD1gy%?eZ\g -0&Ax`VcVQZkz6b=QBPmW_N2-b 5ȭŇk%+™\CdW F)j ޸`;(4iP׌0 ~l+祧\[I::'zbKG-;ʭOW.7_ޫYpz$\FOptGkZ;ipEfnz&Y 7وi>0V~ce#`B w&ZJe=L`Qr2vVF:L0cĕՓ/CG)<_,F3 FGmX\j~X_Q:zTpe FmwiӈtJ(UhrQ&bۇ-r c"z ȍ3KK&O9ɶHwI쮦CkZt;H+ 3yMc.w) qqR$*V~Wf*ݍ^f^(q\sX5ߧЏ+}ֻ<Y0jfoə7>alq:ݔu7jOq9Ȉdu(S]m(c o/RZ&ӧ4QK$^jz.aWMgi8fɇi"L9Z=Wkzͤ++[zL^1 rZD'S;~us5X!qqv_YT6}ę9<Yi 3:׷% +&)۴ YMw'z"OCgp-݇ٮc->IF%z52+(3Yrcû|Eֽ7ɖFqL0qTDI8Ŵ^^#+TBT ڶuXb xB$z׼ǚE7-H:U,?DAu5߶/!ge=9eؤ0JԺ>sll@p0 \yg"?o`$~\myZTpV|vC/ܫDFsOH5}IVtwP ;\H aZfEd}?RXM  yP㼔ş|NP`OG{%sA~=mp;LIB^EY pg k&/T*^N4R23t b~D.h#o;Us|yN,u]`(oX Y >8d_KMыN"v%|aOA:l?{'ο{+_h*=>%'́Lly̓Ml@1; !{է<cc?+Uu5 [)-] C"e9H&lDi ])y[(~Jw+:탛,L9K)dWP #Ō=#( X z :6fJ.D_ xNKcO칊37O<2I FH ƗBvlU;kZNs˲ X+ZՄM}A҆'}+[EJ$^q'~န5h';\Jķċ!HH6ݾ)R]6畵`%YFkA梼H^;~PhzlCQȪ(|^9/u}v:Wm"Z {^&6iS*#ܶmۖ+V|Ob?OoG7!k LU(j|+4+\2#[ae5egXA˞坣ulI]ǽ>ȵᾳw"Ue__9H;`;J ?_<(<}-Yg[";3WJ7hv(AQ8#FfTKC r%+"Y{vgl2rX~o݄qmM'Bf( A,bd-Um zY6xe}I%7](^>wjies-2|$@MP@ܞ/&GoIXᡶ>O穰 cN}eL`@IJp?L. e#ܻ;C]KV.gmbjm)_TvN;nvC#W 9]QqŽ;ABLKgq˹Wcy1e(F_M'1/8.yv#\NŊy\5l [PiU{&oD<{+_ā|%Ѫ>ֽ(۩F^G݄ā8*7026޽T@Msؗ#4c5) NNoS(zccŪ^ ?u1 IsO௬bu?KϾtBdi"INM(ΫRs|ZkɼJHDHw*k9;*hgGQu%- sW``nğ4za闥{q_>Kg6R3WNP@0[EJh:p/r"H@_s i}{W0Cuk .V\uZA5Ҹ;%O )螅^΍ѣTzI[ ߜo2pfܳHۨ4& {;L5ȦCȶKəy:|zܪ$U.D3(11\DNor4x;&d r'Gjf4>g&.V^W&\GX삾嘨쉰 ڂ$)c/]\4 %63}bpJxH{Pʡ2r~KZ/SvEgoT)aӮIhX$yIt- fyqim(s3^MYnVK$}Ĝ@CJ/ 7ҌMl!.bk|yeGh: ZOR[nQ?cnL5ja{^^5/WÄwJ~'Ole^GS^}i~QPִiM ON| zdL·Ke4/ҎKܸUmYkn% XPKs4rQ<ӾmLXd3w ¾{܈m3-)Tb _\lxYxy0YvőUN{M'-j!U/y@%\>^uMb?RXr펴aIsbvG|ٻ_6$&$djUBÇX`F>VY$CN2p3B٦̼ (@csH{bQG3#>r(ηҫ\%;y]֞63qA,臯+^{ j!Ι,v_Œj7a#pcM4{8ԇnTJ*#A\h}CqNkfO{9n覾'7;bؚ3'7ӛ?'7걵 oᢢ٢G9ig'nqw ƋKlnQϞBE FK$ܠǷD"k;k}5bի6}NHaz#,7ڻwх(]D ?yh7|[m˄zc<!{賜[v=:~]b+%1+}uq*͉rl]4-}$%B Ǎ`t.cYh_ֽɔT`m_ɶ`U)/0xݐ,.TI|0[to٢LJyxakx֯U%D$2wkbw]_R%kPM}(R$=FD *dr>Qs8ݩv,jv0,p+8A&GH8 [юd6`Z 5_tσT;FA`0GCdv5' ^cm>3΃ynDu-/ %.yI`&eo n\5;&5@`,A  ۺ¡o`?l9zÓl_˟uU@?6ׯ3(ԛ Y|#7IϗMc!ĚF?iR˺܂ OmN^薆9@Tk<_v~կ.Gm8f)d9Zl=fvXў~Gɹɶ!} xW_/1$n.ŃFALL&[G~ }}Kbf+͆$xus7orK Pr70hi|+Ѭ :E2`"~*zG@Z|eMww-yjc4r4^f!E"DYd iկHpQ??KuD i1bb.{/❊wi[/D>3͋e>%Cc5,ȑ٫^:!0`lМZ㛋`{/gOW~"5f4Gś첡1Jx%OHrE^L@mjol6a]6OE>HU K7iG߁AzAdp}V,9 v+-?E_6kCȫ'Q)Gvdޒlh=&A/ቍ,M,w>1*ڈq12]&x5 ,RWm@j|j &0M>3FGj(睧 -7xDW3r.Fk#A^v=vV&8$nړ;"JYFl>GJOD%D l=kyj@=b0H :=]P"Dz[wLqӂ L-CMgVݑJu)Pń#S|0 \3#؈t^oӱv$RL(n9&STUSPTK8ŀ+VJYF枢vjΆW랿 yg-7%hi%M6 ]SI4ǥV3WZ,wg9|3.SAiM"J"rWBPaJ(c 1v;=E`I50]Y\z/հ:Ajn/"M]'7Nlrs'`KM*oQzYk[;cY)PFn*/Δlc0˧oRmbS*ib9<<<\b擦Kaz.LJ@СM=H&^"ZZdPH-o;͗Cٱ.بioВm"ޝ ]paoW0* Gl-o8Ȥml8cVp}05]+>C+"$ Vg/X%v|oHvJ3MĂqdE_P&q3ԶEJ ԑ%0-}+1=7dy5.iY-H%pdȮ{KD^91q%wqld> c]˖{@LҤq4S7 UଇFݜ7e*‚{6Kx{[[Jłr|kڧOT vIwXIkN(\[+^Z*X2#4c1Bos̚Y6ug ksnTb,5T>9_&tV |(RFL"Pܬ7Ѷi~vn&Qfr;xGN ̈.5=lw/-)i>fu9YF.0shfڃ j.Yj~K9+PB6(Hs !kΡلfYF'2R;Zit a1TBLClX:tws({ bhCK6OZpo,H7RFE7E-IqZ64"jI|@k0Hºzg 3ZXbhI[ʼunG܀QQDKrNGg%S8Q0+m]e)Ιr1MY@ðjȡe{][l4= Yu ߏޤ*Q9ސIYTRLc6nSa )MGzs=WEzGM d : X%G&7E?qaB1e zbOPlk'ꐕH~mWOHtHʕV;H܅:9l1?pOL(d$m{]ў@c6A@%+ ӚtL!IuPu  m mn"?j+ĈJ#!bcSK{f'NL3_&@7& C8f6ktAMiz ׾!Nrrf:1K+u:>5@j_ǮSmrޞaLLR.dHz29bmRڳ..l"T5Jw.Wv. z2bgF6Ϥ}RԲNvrKA*D3շg#S8v5B$-roMz0kx3DeA8@Bg]4i |Tt:Pֶ.ٰOmi0]u }Jci ŢӢm B%tѵ0Fj#\-Mԙj1 ,Y`UW!-6eMWL}glA?Y hrAf'KFs.LM$vG!ӹF+=Zc}Ksp6`z 6f3b%ٸXn9=⏉~aSeQğpbgmN֘M2KO(Q],boHlMqW6xCYCԡ"T6I)r|jګ;b;cuᙨĶXwͼ)x͐Ğ2Emug3%3qӏiQG}nĢYJF $H Z,yf#{˲URu-·g,C t(w9\bXL[FpܷvRR:E!k0AI.0#4'< 0M&nhn:&wZDjW3&)Iscv VhI#rgB8K<{(V!ne>Y&sk2.+ Dm-AFyie \ E[|=(K 6TH+/ 1%Za&4LB4nŗ+P(ѡ|= "Rkn׆C{(Hu"Cݑ3S%Hˇ0~O#bB!/ڐ']g}$SŗVФNN.'S}!^@|gX.ymE %۳޶uܘ?M>vൂ j-n.+*1 d{EensiY|DuVFUdgPV;`)nsțܞCwTƓVloC[z12j"}ԙ]Sfbv7K{& 6 )`{@x}lIݦL-ڛ+ la&b97MS%^;Aa"uRn$g n&:-vfY4dKeGs^K@\.u&i431' r0O&QYbi.E<\XqǬ̸g`|:H`4CM2--Br!cȝ'p8f36-cmɍ6G(f4Y`G]!L4N :Ճ#|wUQěhH׮Vؠy'46veLoþa11vS\IfL fChT*A~TqR-8\2U'u!XLI @f"؆sSOmyT+&GF}awNPf*v;Q+ 5WhZJe[yl${:X"t%H%SoKv-t 6V{U;FHyLF8PwYtY>S50򷢗??b=>Vq:31҆>DK{UPM}71D?__~h4xuӘ~{2vU8PS':R/9X1>aҪSYjqd:^\r`H g$5^mcdtAI9:hsO!-Ԙf;m9 fk BZJu)Ec g EtYU3#RҪh"BHt w7%ei2N+am*K$JBfBYl)SAFOm߾ˠ^Qn&_*R3s3@0Ux/g"jS!UHwڻVWB![ `ق ~k#oiVvUcfz>3&* rfL8ce`(IPVݷ 1Ţ}WHo ĺ5߮%߫nrB 9OUP/?M9I?QI* -J oh# wf'O:=4q& 1hOIL>Ax?~sz}mOOPx֧կfj}if2k(k !8~3\z.)R[|?VUw5i-*P$C j^  `@iOSvdTgKLzjl}z̑⋕?5NК _(S?ETl#>t]ԿޝNrJ]:<l.JpcGmsIajS_ڗ6B74%NqbV|'z_5@ݺ{ Ny=ẏk =6 7|L%]TfnW<_/Q8f $UKu?geO硟of9Y#nS1G?="?K}-lk7 )/޳:O6r]?;0l~X_ ѣy/xT\][(P}޷??FYVMQ_!P .rh4vip>M'Cax+ng#ThD.ri.@/n5a5|Xϩ)|BNEf?|U3}(vjHjroO<^NxT%V'5z=?iQt8W7%@;T'687}t?uz3 *_ O>sq?5ACv4ɴ? 顀w1.FE־u%uWm;nEvGeu[I# ZQv)mL}Eׅ+y#qc!/Ls{5M5׬.%.xFثxOHjA9`yZ4hY>baJCy:yO6~,߱Sէ$$5]xΖc1i}^rԏvU ѝ07|=cMo fy pl>vqmEu4IqҙA_,Lg~CIu+QP)`Tfn@Ҭl1 @\Pݤ'pu_ ) mbZfb5̨~쨿~?޺}mI>S%^.P_t^ԡ~aKjcg{74j{{#Z6*sƗ68W=ޥZ=A"ϗс0pzaS>k{ {v\]N})hgy8%=ucqg&/Y?:H[ADש8*XTzWp[RК}>Hh[N,U zk_{yJ(O  ]ܪ ] f+/N _> )md](x`9 Ayq ٘jS5TjqZT U rؼ $l`cY{^pDX|9:W7؏xh$$^>35w(6CJ_RrT-9eӣp&FgM1Y~6$5|cmǒoӗd n3$0.3Tn;Ө%bI TK1j4mAկf҆'෗܄׎Ÿב!v1{?Ƃ߮Ζݷ%|#'w:^ MN75+]XNG0+@4*_+K0" R_nwvU>fUW,C"G_^ṳF؛xk"jg [M,ӗQ7ijv^C+? 3㴞'ꇒz/Xů=գ҄Br"s=tMxmmTځͯwH~ǔ{񼷞2_VUʯ5g=ۋe%bgBϧ~pLIتwn?x(x]"n4n>u{%UDlb{U/9OJ{WoWx>Cƽ.Xm\OJSm*}F;xWgP>?ԠkyCݣ/ n2Ύtr}mi Ow'$U?Iefe1}YoL9c8ӓӪ^?Կ\:~g#UֵD:IP|t|8Rqc:nZ lhQ43K\`C ӵ7'  4x TwHiY9kub&`dž|>jdy_+9; e|Ȣ3A30P9s?&ݫiVO#o{_ؠ}Oܿq.{/9 L(yqy>QS'^\֯ ~R?N\uI G ;qۉs9VKK͎LjD9G/ܝݹ8"BP(>mE~ulp_[YzQ7uz٬"ŗlSVyZ^U*o5)YK}^_EM[jy?Z|:]&iѺȠ򶘶Bg7ONߝ.Y{}߽~ǾO Gz,[WY-%@^l岕Mek/rvyO[s\e4/;_bZ~}(g7٢ZmbQ.:IZ@[E9T-.Jg,ޫ bً=d0pSz&TEm1YeOn9i9/}o(|/ˬ\t]۱98)oeVU:~Q?ՉaQmYTk{R!8I(/ /Y^7g2+3noOJժJ+ө|ee^\U}/UqzVXD^QV<],dVNRhWebrݫX;Ϻj>˗p}jQ\X7ˊu)L ?8ʤUT1 GI9|6Z[kZ,ϐ^DX22iE*YYwY.Yo&jQeE wfm V̶V߀A]oՁBZ&̅Dc!7s:p5vsr[-nJ:l[ 4`K`F΂ӄFdj_X_o<>9;Sͳ2HK~.8Tߤu\h9=_~zOW٧i֨8;k՟^u;G_袼;dWeYvn@, B$۵Ӥ "O <*yy;cV-oPz}*V8Xd0\kx݃)4sٚMEڻڡM"]TgnEʾhWbbef"08VYaI}4 V7ʖM? Δ:-LӼ bSw`X8Igtc,h d&"]}&;EDx{)^1 dͰi[J)5IVx`Q ꇀ&ՎכWhH :FF.I| `{ zZYuncyPz7PݨFBe~u+RUKPh:&|ҪzʯJ}9a,@Ugn9x<0~Ίa\fD<5Ft͏7kM-%)*YoRC<=/9&߽Ւoj+;.W&Vs=; 8TN*n W4-f1 n|YDzW-:PDŽU̦urk)Ay} 4`-^2elL 0|CBcx맯emltdRR3Ԁ$ef< Uʭc$,"Pb$Z4:khpNnTIku?"] o]9%_pwavPY\ T3{ҘrZGu#d>bjMI6F0NWHk;rXg(o Aߤwe"Lsn"[2})~ʮi\TWp.Ef!*`.j)=] 1Q;-n,+[ثȫltni^jhťz R騫9 25nL-HS:S, V.[JgA6n8MVyHc܄\!5+<伐Nbyp@C[8 I]E@2 HPCw]\ڀDv8+<|UzE8l¿H@jo( f\lW=__+ p^8Eƶgvjx)V7miC4m1q\PrSpJ(Q 5ihE TF!VEOdxj&1[ \C ׿n:fޅN*Bq9Wٕ_4Xۈ~O 4KDZQ*LU|u4]aQ|qu7lgW闍aXJi* W~/>ؠH~˿Vw;M޺ j˟R00kSY}+*5#^#/1TYȴ9nl`L#q=%#U<$״%WBD@Q7W^@56 (n$t:dDAb'Qx$3hm**+ MrmP:& 7p6)'QB7׋_՞eh cPbEEP@* 0">WƗ<rPu5{/j+$Pt+>{hJ,>oɑ:<$PuxûQ)&G55/εiɦmP2HSNgҊEBؑjo"&]T,8)Mx"vԭPqS2}M]^್?Ytʧ/6yyJwvJt/M[p.Q*<<(OBf0Ņ^W**;y[;;hq<qFv"TW218C-{'pY^HnVҙ i_+ g\GDxV-BL{#TnTpݟb{cPwiʯŤQY&&7mՔlbw h %I:TO2WyӭTbb.F`ǑD+|ݣ2/nӀigRRyQՁ4ɾm}ctޛsv^Lk<qcKx74x;ᡮcڢ7}3vb+Jl>es9*QVj<_()-yy?==O;;>zEihdx;$4z &לè1CqY:ɴM]Otos/u{#-o87C(z'Ϣ| d:"=٤˛J@?y j6\35u!6 M&qgC52!q+ rJ'v iq im}x XG%: Cc u '3:/m/qa/aȖg:/͘=m+x"&LuAy{b n8zFi.6r Bv ɋ+R[H$+AIZt9QP@{SGcbfaLgFOf%5&?WZu,Mۂp2ػ o]Mx#kI>쨽c|\$.T;2 !R@B⠻욻1$`*8j9kllغ*\d |Bv2\R|7OJfeogFeJ:wÞ+~f$*#&~_֤M\ZiՂ.%řj]5}lg.}a3i"9Pb-'Ma9˶^DQ B)LWĄE6*с@^ \q^aw*E c= a\6Qa"YQwdHX2o%*;Ns2vߜ\9ssխ!Ӑʖ Cl_6ě<ÜTUgRU2v.g pCOƿ||3!3ox 3UUA)U´t](z=Sh 0+HӃHdσl渙Ed_:zJH:d׶ev3W7:*:1_9KnTA-aՍ9_䝇t~`aDPm ͂O[wd25]k>ŵt1J10 ڶ`6 _"z7La'i:榽DcVi26 jGF\+Xn!Ve9? 1nD7]]d"!^,ǽ(v"h oGl$0}oPfCր3Wj=(n' o_W[dꗮUv,8["03vyt>brg [o!4f(t٦jM\$%عGl Ff]ItBfr?uh6,T@r g9j;du:5nNyftTȨ`{iYՔcc:(tQG b Jx&gߙ:VOs(ٰi^v dӌ^E<Djt`h(*^$bl)$q:MNszee"H/5rMo~0ncG}[R N3(d&[NLuJQm|j@3T7smdzSn1)(C㈍ϩ뀄X=2RDe^%!E}^ŤjP@SCVX׋>p|}>~szh`~4yfS_L~/OߜP1=-bV ei[tD߱6^ qZLEFJk(P: 'C jPI/au] BZ75^V]w \ÆEqtѰg_\MbvQyWNwJJr@|C7@G̮kEFݯJTfC?)d$aL5Xct w7捃L?6_sxzG'I,AB^U`aZsC7_3?! &zRcJJ7t>Dr4 YUĤ^ F/:.cig\vFӏ|ž7""R vk'S {:\dϬ OLFÉل8s|_U4p(R <5ҹ@{N` f\Cz3olt 8~'%9p?6C50\I(JDF00UQpTǰmSIf3cxe;xGU:uJ!ٵbqfHa6٣/=e05*2p2PT"%gST&UO.PCrN|zH- ;JWiN8ba@C~WCګ D,LVE+$⌻dljAbi!ƃ_`XK 0 ql6[d4emZM֔<љ*G 8ABk]?iH>@9Jd$n<?}7"*2]~?Bsvzbxׄ/"j7ADmlW&;$;>9EM;Z;Ǝ\(q8tZ8_>˖t0R* P5;,+x2Xap 4cEUf&{(h/5d7WUV#n%6O M:\, U,5w[¤ZCX(J RQbZ}0|xu$r'SS;H8e8 -HSww_INYJrd z h9? i \mEOZ[;5FP-):тb -sưfMS1 ܕu^i fo60jШ"Z[ gI` s%Gܗ`aiz'T&GG u>\TgasͮxTOyJaㄠk'7%I({%TB%zʛz(ER Eg UPufcՐ)Ucw'? /  N~Nޛ*W!Y]-ܧ5Jcfm[ ~Lv)K z^qmo R  ^ 6~^"EjLM[M!};ζ֙"X(ܿ`ϯ׳ELS϶,P=ISdztKuk>񷝹+gQ <|nMʍ3A&򛐀Yŵb3jZ[aڴD\(($@IjɆVeʅߎ0K nSC߀xyWR͂2w qTKI7!6-|T?ԑmȱW'uhBk/>z۶R&J1Z[6nuC>e6(5hw1g Q輑<7E$^pn#ᄵ`jc_;>b#t2E3X_5yp"mftugCeV]6u'|[•e\zӼ/PnRh5Ń\LC{v'q&}pMϋtBeۢ`r Z.. {inh@Xabǐ94Bx"ꪡ)km.Y찬lil~5T.zH#,&CSF3AҳJ?0nû+J$ `ӣҢypwj`Is7^lh'N2;## oF#fڹ'siI*4{n@9iH2oߨ"<봘Īm_\^2ojj\ RnRq)HpMt:x:>ֽQGc2"W6'(l}G0A@>CxtyCYZi_]րD7[y &g@#%3 fLz3{)*˃Hb1\v $ #պXx5~K3!7cɻ/򩼮7DƢ.D'zAx3K:eTm $|e ql,4` UʔI'Kly3hHa^wDIs615mj [>h`=|S]Y:S7XRUTÖ L(6po0|_ s9g͊ʠvK{R*!W&9Z\kY“v#( +DE&tTKZk fyaC5'^x4Ż_=4,?t'd7%$;XYwf ѥa9Yv i/NH!hdά&D=|ijw%6u87!Vx,"ĜO?;jFNJ+On.w&3~AϞ}wT[# w,bs{@uOTow rY&ݠ$uhY"8'f2,@j0q$mUboɻMdQWj^=]%x_"j1G3< ;86UW;Kv$-O$4Ȧkp(fK^ing\*qP ,WPMvB,r%8B-DAPgZЏ[g]mCMb|^sJxʲw>^@p%+V XFÕ'R@fxB yM?ZWs^8+~ޥ e( [@ u[:>Jb^Tu66o¤+"fЉ.yϱOy#n:MA^뷛)h Lulk J 2m< HV1CWIBnU0^[-!0\?Wemբ3l6֍;DEL;/`}v-!D_ƖbB'{y.'t_L7Y7;;pp2|ݕxGp` Sl4(rZEtpű?n ]g[۷wo޼eں|#{=n3/'l,*:tH'dDE vhnr3.d3/9PwdGl٦[)a7|?~3 JF4ͿD DxEH~_<6 ϔڏti*:ش.O3Yy9NP&K㵛d]2}+0I{=x,_[MX<;|ˍ5b{AaE}'.Z t^ytHi a)cl@.ÀB.U tFq$`i-ԧ65BwlgWn?/k ) aX&J:t,ȏ㛆hk.80Œ=api@uEyt^R&Z2F8$o jwx?ǯr_ώ'AN?gюb)*!oeyhc*bQA2'7x0iIID^Sѐ:_ {ѹC`LvEM ȨeA|8drp\>N7&Ǥ6`Lol^¾Z7齎Wqс)|aJwDoip.O8c.ԏ~ )h::BxY4ԤeaVrzIfK%\b73 |qы7zl eO$URX,S.ݷ%?MF >Qr"/dxDYwM6# >-!QnنKP4-U<8yHvWXWW]YWy1px:1Vzw2ITL?{nK :bhs eltNnaeh'$ 81Nv~ 1`#XDPUsQ\K&l3:iP~xW3r1 zH#61`V^pl@N9,@䵄Y|Nc2r1Wg̶+?E>EՅr@GiXq}w/[8aIHc/;감VlKQ3RU/rD̉N zuk$PbocNu[cNm54*lʌi^ %%E,p Aīe袭/bO%Fi|#j,츷aƂolȥ߀6:1A៑nWtqr;~a̵hvBSY0цBuXJ_lwf(eݻ ?^+5stAf^trf3qyB^[C\sk?k'a{;[[.(7ԣe&9-kZx؃˲J4] ;mG4Ξ_~YM>Kz@ik+ydݮTBXÆ71{cg t Gn/ 2 Vθ:֢cgCQmw:k#=5#/$v*+# yyi/܎S !|d{7^*l@;PH4U[; s:ZŅt6MmEs-(F`6\f͞/u 6_u;dKO xj Р Z;ֳ N/PSG`X2,PATI'uW8ţ 9y2y}zcSL汕]Q}]F(nн;^dϚ^8;]GpL A|ny-֓msjN<]l&~pRL;]NO&|2Pvx6x {& +|51֝U{8/w )>ٛ&-(Ɏ㇢nsyΈi>/<;NA(Go@YM]`ep3U5o:CN2ģtd}1OLJw=^ҽ,[w8gɯwIum:~hMc$([xz|TCC< RȅQxjbw]FK29ARhL=Mߩac:/{Z`ST#jO@LJ[umQMy~CǏGz*Ôvc\u2k6&@2,n~>?buFQOJ=>xR'SC9Ty#,W+i0v4~k/6bm>Y˴\o x+Y% ER뗸|q\S -0,yw wiÇ{a0kv/ z5k 3`.2[LeY%DѺu$;=Lg;XQe J'`|gvIoeÒ'FV[^'HI~]D$|/a FPM%-d%qڂzIJ̜y0 c`(cFXESRh[.҅Qr:hrַӺ[`1CoDž:i0\(?utc؂ }Gd-S ԅ&S4eA [";-5V#CïPQyۏSbtY>1؎lݾ( A|85Ηv_<}wD J'O_{'>~?^t Ļ_t?<>ׁf#w}a8???|O?>EFk~)^}s*}'~utMJbS%PkqU}1i@gf*>}pNkgaJt]~z%v?}<>S'5jEXSQE4Z+4qSL#Hj1/>~.PiU™"ӻy6GWuvrP`/%{nN=At}î:7"ewل'(;BR]h*jWfW3YǔnA8i#ՕbU6J[*"^;B@ F',`h"$Y~}@ s8'@gQJhO,7xa/>}6ǿ1܇?1aO豪 zԪ4tg>m9Η~JNdBijcS0 ڸPsD'$Z2|AD[N4ku|̅NTO?(x( mʎoY_@.lOI uK~#h+Bۍbl(-KD驵a0ԍޅ <6;7n@8ţ${)'hAo_C:kKV)^(BOFoUpˮ5EAzpx}c ,%2Dym,C]A_VesSYKv]`bUN{N;!ThCyWϥR~7ٲ"-*wWO8@!PogW5t1NAiR"E z|t9dDv[Nwbi_(2 3{Z7ԙv %|2% n.P8|w/J5qU %5!I{2ҕɽ/@q݄NEË]EQ} ?ӻybP HB&T/X6 Fw*,>HҤxt$s|ąz\<'ZsQ ~V>'A|Y(|$؅+sH4,,s~<"חH>]MDW9:Wxiqqq^Q1ael\!'gy*:ڐAao"YUξd,fJş Z=8ȓw-1H'Y,\$;5:(= ʶw ͩh[λw E{e]"%oZw3bI}Ì݀ih5MۙU7;CrFbcxϞ=*G'c}xFziRl+nutq&NطCQ6={_}Qk#J80z{qBէv?FDh]mhA&^ vu=.RɕohS~Lǿzw0>Fq(Vu~k[w e(~uƱۡQS-][~N+2q1|H${L-L3 6AXIGF=B&E㽵p "} jTԁNۭ.x"/5'-闤}kqx4 pjbYk?)>MD$kzB:D9mudkА>yQdbKo~KAbI3V2^[|$oh9ľK@HEf3pғ`T$qÃ/fT 50yt {!4y,9Ogn0R37= FGu#qg*ڂܣGa4ȁyܶi|.ʕj^RBC[sDOVeJ4[ W-~+Bful\b(]ۗ"n;Ɵg߲-8Y:P㙒 ~N o& J7Vfe{[]s9agwt:>?Nl ,=T+o' \͐ uӳ_>I4ϩ?fGϻoN_o24S*z'`pZ/{n/_;9M~WJ+T*ߜ/gjߞ\U>:ɧB;atS**[aby-h$*j?r~j(~K,R_܋["Fp$W%m=t{[30EϙK~b!wU9n#PaJ/>x~6p|vMǃfvhVUԺ`Pwk 1ܡWS2-mA0FbmV{go'bgo_QU_BY[_d0/t}]Yw}\Le>}Jl$|FR1GIbzUyj"WRqNCI_M಑!l>JV|#DjIa~Dc@@ Jy/$3Ňߝ~T-qti .Lߗez;[eoҫʇ*ͦ}DV4p!^ iDjwy=Hx@sAcExi u*,\Ko:hڹ,e(5&J%g˥8J[KHo0& ;тE 0P$F؍b_V{J_Dwbz 4pF*s8Y6 .͞t Eo `=hAx@ΫvJ RZE?FwKuM RgM-Ǡቛi mw|*%KncOo( Υ)Q:g=_"IF )R<*$d_)UIR_< ]p(W4E=p `h)r]K+^,V@88uWƏv(D9F8E( 9KI}J4yzCUL{pH,&Di/əs/x,3+B/~zkMt ^(NU?C;?H2/,kǵ/ A: ٝ M_Cmښ uW`(3qz*t/ 8'Ig_|1g-+4ɗ$N'eT=]dJ|0[7Znq߬jiCΚV $rvsRź g/Ք6)UaT$mROsfjڣ:F xWƊ w"lѿi{l`q?aZf'sV*XCP/kv[|afZ%~`?Z)?%ؾu$MȱX W+I(`kQl*L,% x dwFި lzZWמTM6Jgv1û]:1q-H\p|cWV|֝aڈcz>ˑ|Q. A]N.*%oWyccڍCfGP'I1"1u/t1ꛎKo89T@9 JprS\#{iL5W?wiXnO([u WxDL l>)Oi/_ QvH]٬gthNuRCpyXġij^Yפa&X @oIp4f -?@%E~6[h\9,;Q|j!y#rqu{# h$(r-¤8Bj>y%ؑtڨNjzLƏ7_t58jS[?O˒.ė 0 כ-;xDAo3/m S2.эK cbZBK j-e:=}s^m:w*"& S$50k`R℔v^De~-#ۀkᵎn/[j#'vl{W#{*Ũ;ݷMYw'0^zq;>܀MJQqIӁ+Է+}#ƅjyaM(9'<7 N}ЧT26VӢ5@ .Ț0x"5\IshS l*+`q}8Cb,$ŸsNj/vŸLFWDzDo}F4Ca_~?k&2]'b2 d_[+}nZwW۟:.0ƀF6n: KdݾuLmOǿSpE2sCAt/mH[85.2*D1xBN )#9U,%Ǯ a@ڧ>OJ1I ]|VHSUZ?6qJ%Q 4AMӒ^Ms\ :4T],*^Cfn OCMإoTAx h0e-5Q:L4)vaJvY~#PvLWFLQ%K<3p5a]mZ`n~L]-[ غyD e'Z8(T z)6&$l:d7ElٴUIrLfIi/J>a~yC0rwr!s6{ )6"! ҽK|NFn(uZ k⊃dGp_@Fj*wY֙O N`wM,Acǝ7xLh\gE˜}sib;KZG5^'x Sf&!s!UQ(1e}Wp"^Lkt%OU!DD⪓ X8eV%3!R9k$K2:쿉#V Ȓs:gx2Pt|Eh=7㡮rV [hKT]5~ @uIT HW ـ9f4SY&C.)> u=$]EՄ(U[ KT.=h#Xm-DL;ԕv;ΐ[il v֦Y=şLp0\Tz dxyǮ /t|׻,w26FzT>5ڡi2gNGxց0|⬨nuM R@LCwNG0p/[ˢ;}tn܁4w3QQ!`P~"tP}ˢ/7a4AD5@QWm{FIFn{݌kW m£WA`STjen?=\|C4hBQ:WNO?<"028w3MO gGJ(U?Q_b1"k{HC?#vH*aqGN%(談F4^b*J ] *dVncnL>j­>ЕR 0zL{$` 1$) y-(1bA&}/xC1wo/P %EM2TK;?e2 7&N XTbsJ Sx}?ds[ Vx.{9gl'rj^˂H+<^T^@𪕡v;Ӿy\@h }auN/ݟy[zdI0~>]pr>^TiW eSn).Yk -sT=ׯK&q!od,BPd?b5m,(e^s$mp:ctJjzC;˚^巂7:Oj̡CaTt,!߻xb;sD ~bHpN^#VKlNO8~ؼxw_+6h޶pSL(둏T,Ex5x`Zk?Z{ `v{S~S\Q #w6"/\yd,czpr2yfŰt>CY(+ZA zEo|{g) er)wΥ]woә<`9eT<ߎc6T=e^E+th^ɡ HdEQ/8{BN4}L&\6&qFVYpi,B@Fٌ 92!NܢtO~/ X!mg]Ơo#$ M'Y}$0μ5m6O<yNF|%3zS](]}"Bvlqo2DEұPDbMT 9u8R:hZ%iJpIg.$6mI4obXR`3 x=0-6x.?lb1yG lCS`sԔ\^r%\kS&W##5 LLĴ@^*գ={2ƒz+.\]wBF# (v4d}S3 m ƫe>ս9w[PRiV߃Z#D7.gHxT07 [uR b9[6zYd4:\.w*VSZV"6|lZ-#b[&)VV^!?OauR$*fl1Q; m-E9ufB]c04Axe:ï@XOk qEсN?>뺐7[vU68pul\5aS{ePƄ`ɗ:d5/.疌5qu-6jE,- t̀-(2B:si|3㤑d !5L-8 APd%؜98U &ӥ$9lͲb%f-R%,v`5pLfбZLEU2so07$eflH{$JB9AS \r@!ST~f}C7(fèPq $.,d>O⨭&D]5 L4x*jP ]W-lؔmS"Z Vy UFy!&Bo읾C@žDqׅ'": xQ ͎q1$:R;yo*S>@x'Pe ?wQ?tY|;H5d( V'[ͅt+u~jqtT׍X]eIy&SG]Zʴg:̄*+vT 3F USnKk{{ifpYJ-R]j5! f^,!xlQ#Rne>cȕ Ԋivs4p w暖k}y!+,oh5vܝh'OAP9? rq~4쭼,Ez1zs=d>م=9L[=J 5]& @o"uf{hi %Q^(\G ZW{`_9({X]6Q9 !k=ܳbkذޑk<{_s^泙"e8%hRSͭ>]yfOH^s1#HMe~up[T2UZiv%I]=v젓b('澎ʼn!ZI˓?f"KɎ1ץ”4F.mǎ6b] Ȯ/yDJzvņfhX/s1` h&Ћ7%5ܸ(M e"լ? SAׇ$8>eII: ]wH@ 诠t HTٿȻQMH"w1}H8;<:p6Maea}?ZN&x@-H x+V tq;mBHAyGsgՒV 8RRx3ɐ;zl8<|yԛ6J)[НiX,BѕS?WC6Pa o-J"c8؝!ͱG d\q V$ 8kPL R=7n$i6a`Y;F:\Ht8W4/g,&V,[pRMd̨%9ސ޾Y?͡qc 3;{Ne3ݐȾ!nJRT CtHO{o#HUd6Z֊|3B=(H}d|u]\@ $l@.bJl#iOteiK%F)M.JJ@ \24i؁6R3ͱc4:á{X7Fݪ| \d†EpgjJŽ,>Kvc[xW5:Ω!ەh^TzWU?Ƃu~suH[tݭB&KHiC, v@f!d*!Aa3Hsgؾ y='~Òyؠ;8I=xCf̉ÓID=|ИEI{8}x]\Iϸ4s]C?`aᱜlyA01ڨJQEAzM׮yϠK  7@_Xou]^]ASxà*|oQmtGCg>]5@%>2;,#8lZw׿SþͰי g"4~՟ m0>+QSނP[T<]7 -8Mɍm&gGv/^/~4琥u h:BTRǮ!%.cZ]Jl!}'ݠemG}uїqngT=]Ӭ4_O-80vr " ,Brr.w_P9W{u&%}І ]V."^rp4/w;-d vȱEmU[ͦhdGNJ͞cxk?xE.yWWId4LFvd:c -ޑЭ7Rf:͙F=>"Uْ  m`FA0ksڳl֦-wwg~2Łlv>-puq^~5d2XHW  5Ks7B2eTND^!NBZ/!<_Nw1MO_Pg5הHywbvcW( c:^~:è-jm8ds05-S~TZw\UYHGo۩ ?74bZ| ;?Q7 TJeTݶH:gUu-j JRͅO : AVD70l(z"rCrRSrN3XQ@ꄂ7ow_|n{g 3'9$#u.>*P#W=͌ &1hIŮIU|Jy`u rӢi~ԡVͱ `xG~!Ql vzMp<#l9׏-t76ء 'e |V Vc%b,򾠋CjU Mˤ?g&{:O@ z8+ :d6W*1k_&%iQAXQZ%_rLo^٧cp!x߰/ro[Uaɝ⥘_RWNPR OWE5ݿn{Z2")AhS;Q}x~Z .^v= {$TZ)k=LMZhm)Thn)zWreifmv~|}w~/, tցօi;wkgNe9,ZfTK6&>H eoŚAS+b%!T Ij$֭g0Eo6fЙd`il0r”[zD]vM +oiOQG{~Z頏vaȣ-)8FS7aېvpqs8kj.h+N"5R< fZеR4a<1EO<91kU۰Pd5kVP`& ;ؤ8&<,hx tq%'+_G^Zժ]mq9FDy݇,[yO!V#X?tU sJE̻k>>dYVL_iZ1#vkZ1|\m̤-\~~5+e.:d?Yyfлh(yt4![MzIa Ղi$TQ)D5j]*D+?LW/ „AgV-%nBM.77is`7/DZNA)lhj7$sb/l+Q*0n |%;c՝3V &MiA>Džn\r;N %'Yju{Rvyg<X_FmVoi= ≷8xwJb VHdk W64B˿*YT]ؤݲs,$]NUgu#8\0+'?Df(4?[/p!U°B4ثSRoV$@~ 'vz24 ͺ]߬Jz@D ^99:$Ϧ[O"!d7Ψ/y *|FT$֎%BA|tY.(_b- wU3e"ô[KS{07!#ogfƲ:|6QɑFt'O{ GD p]-lm_Ak$O b I+b3&ȰF=D7K"PXD=1KZBdi1URhNb[Ӡ4m' BSIBo۠> BbylM[ǜ/سR߂D-/A bϡ8i@AoL=˚{!X-*^J̡=:ZvekFǣh%.+"^ϞƮETJ,wozU/dv`G ݀%!zf.v_k$7dq娙Inc2Vm(Ԏ-^,TY5P*1ծ'!+Wp(R2mpo ^ztYXeT5Lh=Oo5H{o ݽE5 z#*tHf5b(PBE[1baݩD2aHNJZū{ 6 jըhf@o,n?3yFKQȷ"rn<q! W?y:G<Ch8/gW$Ug<9f;/#T٘OB"7 ?+ooM֖owNlg6:/r$Y`߁)I?t"|u,.'-`h5%,OZ?[ )DAKS׶0%Sh+CHũ6uEDM|fuza}PHp5ΓNb3:O)<(Gw܃ɵ>* \p:r2i%?])4S nlƂ9< &[bV/ܬ& *WO_Stk]$sz3K *7L"tA6mmB )vw :^®f4Jß9$6+ J|SqU ^1{G}wZޥJh:r{ {s59<MW{Q6᡾?ɵŅk)"e|;qmMG>)M؟NO^ft ipD-o""kY&ԙԱ6`\/t/N_Υc_B֦&4&o߾;%6vϔ[̽{{ۯN^n0_euYLɺڤyvBh@C}`=APbֲ z0DH*9׭,UA1!U^T?r󑆅yho{4 ȣ2Ntѩ.`!N 8]J芈{h4Et-n3k#au86Vx쇅\RY Dx@3Ii9P"}tYl@,V pNLK6e]x6. dk#nh>ͦ7+@`(X76*OGpNCp5Pqk_+M@AsKouTcÐ Tl8\hTI[DVd lsU3^]e2 QyBg+If+)\hH-;#dQ-P8#*@jI+lab5 `ΐ(TkPXESЄ6rU۵+!gIq0Te,l閐-qgz~)՚T25!|~xͫ$X@8zq܉ ٳ;_)`q8'@ ɏ b"]JhPWWSv8 g=w' kjpO8t"hI=P`+PunЈ$V oQSuMD^!0I}6k?C|8H׉hV~@MDo:;3^mIosUdffR8RL}vMcQ%ءiVHclDq͜@. r%br` xV5v\&9F_5EFXY;>hݶ^_8_g&hq:nHi7Qb|]|q=5ֵHU,pyKUѯbL ʝ!;->Iguxmd溠/H*$fݼSk$.=TƂXZF{KI#|T9V}E'ػ)idErũE ~jWbO6N9,=b3<0r-LցjPLO1bUN_AzJMUpVLkX.Ք-WkfSP Umpd.7 KIЪОŕǡ؋;I4֨ڨ>?hhVe V5|M,>Է.A!ܡxcсkeuQDeF3꿣#MSUƧ*6]V˛٣cA&{QWa$]=߼{@1x)UbU+VsW6'>~d-^mO&rЙ_?S>f/[e΃[΀Iq 7Wm>qJw =pJ~k |93<(cb[Df2=,@o<Ȳ5:Ԟv.Bi'x` χ^M'4 LUL`Xr(ԭZIN5*Z@"& ]ޯfU$mel}멪'׍ CnM฾~ 2퉮8_3 _4 U]FIoAS ?3MxrfMl}7@v'4nB/~8,q#rMTf1nPs22a%&ZO(rOaKS_qWds#6'י4kyuW`](9xP4Rܱ&9u^=̩񰐫3"BIp.(V9Sv\0͘hQt`9ݻoZ_g}6=GcA w+7Afn[) ^-A1қ< X=Q(A?NjA *@\ ~"FDqJR' ǂ^G=;T˰ى#([f,$^iJsr?e84vsY@uIIIOV[- ޥ/`i+%P]5\s.-5I~vb1U6WFqhL :+t;_у.  o0:ʡZPGy~1F|nq,9"z".TK" cɝL6k׽kj9rTq4;mdM/N0úC}ic"Ew_ὕ/s+B:(! ]ݬ!c6-*~<޴1tDl7ݩzk>95: &_^8Xдn`r$4>3{mvjp!>鰼'^ԩLcS;nU^Z(=g,f`.1{hjTϒ_\ + 5_{jd|B/F>i`QKFJ* Csj;#sc'"]Sckv`*Q}A!ҭ AI{".Eiڣo}x6d10|NnCҷA'>-@Y:oq%8{Wh>\/IU |Χs)mTM,`(TXu ZgW=f 5tNLz4[[Ú.9/8mH(> Gs.o @h$K3ќB5dcrPwN+.VƺIEE%);H$f ssyP=-RZVG|mqo݆Hu +5 v\7_';tw) U5~!o8lm4~Cp} ku6b"9V\)yM࡞4+g?՚d^^E=d^XF\Gi9"}Y|ϟg=(x4>[ìH`]PSKA;}^$1o!S}"|{L -tlNn0mǽsp N'4K 龊: ѹ7ɟt9XO>QD=ST &UDE|;cmƟsXvc6hQQј PΆEmkmfOiݍa z+-3-oXqk'By5 VGjO e@]I+Fϭw]5dQ) FgFRjZ/ŀ=fȢK*39t[o({TsL=$CEݣcI ӊvofցJU'-kW"?5x5+'@63rRvMbX(AJ3Q|xaWsdձ"^X;dJZad yZVt_,FN^i:ڝ6 mw&㭆P'>z<"cb53iu5b(q\=3@囃"!A͊')@}m&7fdMJ 5#aa wx׷n,9^k> lKt+ȿ /0螳'MZ|h}i[о8v{ & n[(h9:Vo0^Csww0_8m]ϛp}TǍo2TӝEd: M8uWzC<8ʽEi>''i&;[dl]3$GdaK(Fζ?n !_ǢnS:#S<DVRwC`zJf3X`vVXXN[FlKev9p9|cg[W-RV/PEoR4Tj"n36 P҂'{h# ELȦZ{BSRNCJ;t9%VҲPsp@l3ZM|9ĭz[Ц0/ߐ(X8Ў?4PN=!xEQ*O 3@\MЫF #lACӉp|P _ǭVNY>JV y!w IGNu)%|P,nt""!+HcؕjQgu Գ茵^Xz5/qC@:3ߎ9kL -Z#u}_jn6;JKa#w?Cɪv[Ca~ vMMjA ׭_}2NglL˺5b|X3[̛ޜuӽڽbtl p="2/Wݍ,J=p=b4@[|ԩ]Yz5w,W9YTF.=u3ݵIfOf3aMY:%##)k E@N83`a՜0@ӆwrKvEi ]w%^9W/ˊlivڅF4)opQ<ٽm9Ѣ[m&Zԇ2JIh7IT 1q" Ĕ1qs [Ͽ5^BOa%t5t.9Bmb[!dsb撕IKQoxJ"'uKwNP"‘Hn3|ⷯ)vfؙIuJouf]ޅoL;_YCAҞ! "ը1qU68TN C )~(a]M ]0kO- VʎRZ@K+5 }_\?㩟V{  [34r*#1薦SvkŎ8rTҞIPK@u@3QcQa5W>2Dprf$YHnpqe yDDLx-)52 ˙(O$EL̰ӲϜvB5|IKROsmpQ-.+n}ׯ d3mv^|$5~ AEWk:XrV;Q)oFT:䦶k,+3t+ d4?3"FnXȔ#⤒]9\3/T*~wc r'q,&.VP@T8 5.k'FfZ3 8%2AXoCqx#5w|ٟ3W&Eet0CKO.AE!H1'_j֯6ufGPNc $:~iSHS:Q 4!ӑ[UJA49VXlxg$6lF[܀[ݎEx :?zu zDC͟p 07?n sfxK.*0-g^[O#PAxm%~4puf?Զ]o|'ϸyazyʠmIA,hWp_]bx(7]GP5 bN<|ww{Puq3z>3ؕp 2WTYsp'YrXUv,5Gz\DK!7.|pG A01Sz?7a{ ,"02:*Mgꎒ|,4:5쌃uB~-}h Q$S}p1t-fQ2oE~n9Jh3v&S-shy&ŽeF gagt"-L@b>SZ;Φ儩!G)uaeMrͭ G[w#4| ב}eD+ nBPTҍ$+Yݹa> MBkPR$=:n׎wGbpW]<7c0nTƃ8K sקP3ZHoLH<6A; ? ++|97Y!(k-sr>EtT!P^ Ճ!U*{12-G";yl_E$azI.P 8Rya%z^NިgH6Lo6cAC88ka$Y+"7KnTy~SqrZ?f2#*fu{sc! $94*4e銕c~zkOs!}n@:A(4JάRᢺQ)dңNH^jrH#BscL_GsU*;>9\n 2ځMGO ȎP,uu%3n(|)_Y.|<ࢷ rz(Q%J3y0:tJo dd$%9!úRVpq`Eȅ 8p<ɲT(]\x.qWYNPh`>&*K8 KĂt'!R4@x̺S7tù`xϹb&JּNGr'ðz hJk:̦@sA7SRvL-FI˜W@t sJC0%IAٞBfq^.$_!~C#6ӏ 34X^iOi̷*,mGL vQ#-4qQ'rNrb\4EȖ6c[ӖMoBFڶT?8bpǽ8eTO1LdPhz"]@\6{ 6H(M%g<Ԥ0i )!Wȯ~|hhr]7â]{k6vEy^}QVB"ݝ6irM"qC/ | hC-6/ߩ=N -;zH nuK7@>PLj)Ob%r(Z1(H9 C*|zI6Q*Z7Y҆j*54{ZR3,vr065`f 3S5/orrB;)B~uL5Q kwLbL,z5~a/Ê-$u]8c:Àh[PM3!sC / u$R"CqRa< plP=>Bb,Q767661O,b$Fj,(ֲEZ#ME 2v `熹&3)(h/Cty| ai_E5S,U6/NC+~ v點N)9"U^oy E'gޠ 0)vf8M}\t:=Xz˭,Ԙᭋeg l오4ɒNJV7\/gCg <O0#wv8ag8$k{_5D̻)Sc>8oy|R|`@elYN̷m^ }|GU( !M4wqYe*X\7\4,peՓX = YvaVqq E)8 |\zgA yY%@:COKxIkW{{i=b!Nl{-uRD;X {t.wa|Z#[!ńfm!}3V %C {/|OêC^.~ŀCZӖmNp*]`7*ԵL]X:=mE`yt1 Rt}deKH .h'{ЇZx(JGyÉ4 W ݭ'45ŰLOC~'ZRNղ:rLZ%$+ziN]ULK;w D͍8JpZU Ogo#M,Ļg˭anfkrP?NP7._rFf*$o~v<7X,&m^&%ۑq|,T&fdj"7w5KBmo;$a$&gmS\NG,& ,SyH_2aC  }]z)Cڙb~{帑W %* aR=)pZQo:X>jid#Ѐ/3<Ә,q[FnNݛO|2;VCt<9hv(H2 +|)H<:*ζpS$ۛP!|ŔKwrkM`c]0=QSmM{1j$~_jscEoy[Z- ^@2EJsz}|կ,J$DKKэ޳MlhO*2|d5Y%I2z9[Ԁ_AHrXJ&77F哐 yI\~P6K/׻yP6_u` "%?gy@nFf?dRפVp- (QDL/0t;u%3c #4|׎yf'6V*g* !Ccb"z1e -$F̫S\ AI{O{bԑ:u2+ c`eu nSEV]xZ[ZAAi zw?\*^2B×d&>S%VPŒƹc.7ك1pY˨pȩ},dɡn 6 jfzN| iF{v:qi֧%&3k qO}CSe\n~E ۋuusuX1e8y/л3 wwyo?_yw?hX18~ǰ}鋔$a(SoiCK}Y@nzGnDsuP.+.X~>m4=bP0+g(RR@nm r 4OVWm0-M^.nܑ2z]ڶΞ67pt[AȳDxuj`n+3 یcX/1}H%a Jďr-#H oxin -N:-AX9\Sj_r[6 Z@aC. ?SqJ^gW>Lq#k&^Wǽx:;5%QpJ,E8 4 a#{=L "+tQiD'F+iN=]p[rݷh]kdG̏} ?qS8F\=)o2شe"oaM{`]$44 w/3<}EJ-rr=Kow}NiKZ{ڐ?O~M*6$ߴ7/kT`FT!8, i9Oo 2Ku/!!Y .z;':Uܟ;A0Og~n@1?̰J8 TF`\ kha83$<_Y0>^L G 27Xf{_+U84$b`써_nW1ws3mCzt>k=&pVswGqh& rߘmGxx(v;tbX\\^-/^63og=a\y~JS[zm 6UdX8Gzj'~52?^z9dˤ1ʕnu֏_!9:3|1@>뀮$4oMWI)dvj.D$ZP cQɻwܺ&Vϟ?tҭokS?:'Y\2OKBW3:id "J'7-~ qbZxrw'Is0rX&eRYNrJ99da#t#sE_9?\t+- 2 }kgVD ˤchMng)CKrNONuye%>Bw#Ϟ #Hq@*qak%I7I-vj%l~€OrVL$<K2ݍ/wͬq]82>ƟG2̍{K2T=XҬM?6{J X/EdS㨼ń Rf-CJ$ۣj|]V#cК7Mprh05/B9 N ޮ*/ 6m&ˇ&&{*K h, +ӆRey6EdQK?-bJ!Oƍ e! Ad{o}a:FGDP1zm~3N>Ao'߶ZXb%T\+!peR+׹dZ_Rb-,bLGi  ^Rފ48\+@)TaqTHcrf nr$#_yq?Cϛ;Y7ժ%warD+MWWAB6;lY ؾA]pX-rVLC #Vq0RU ̹9x}V>+C \Mf7+LGĕQF?|gs.%0<$d9*ȯ}@Pb9c ,nDZ@1UKv1jv@͒769+5-X.9@oI:h}1<`vCJ]1] ە A>0NsR Ns;CDCm2xl< `žaz+|wG@O41Y4H#&$p.ɸ}As qL$"QQF}خm< =S6(,[ҋ5Qը9>QIC*{V z~ޟS5 J%Y ~!'I7YI69j ΌGgfy0%ҡ' ټ]z34yvu?ýfVyzr|g';Nп4 VW Y>RA7FaKSKf nzp7^s5*swYA)E++I_ܪ&}d:IHF=*!n揗NiV0~P̋>Ԓm)p꣇u9ˮY|vؐ_;凕|}:={~nDEowOL˰RS~ H xp3F`͛ 9odWߏ #jS JgZ9T}=ѽB9ϓ%}F < puZtDƣ&hF|ZbqC7;&K@gJ遳zk MBp<ݔ؈UI`h2=l(oAa:Y@ba<LCE}M 723-AgGz6nK9;,<ۑDRBFKCޭpqԨG z&5X_0 vru"dnޒL-vLuH&“',$՝ أ@kƛmOOOM2f&!ɱ D; -US[rL\4V}EZP^+Z]cߖ{D[6t3!fctԿkR^T1v.+d-nr2WjћQaw!Cgw \U?Nj8(:*(!QDc ŨicLĿfb$BC +,4yžt`kagH&LN腢|nEjM0dQd(j[Bf%*q{R3_D +z]iֳOtoN螼o4˘K&x7g/IhhơJ# [Hɼk9ؖ\fbuy^N>LRgۺp˧Fc<FߦZR] tN_.VUP9m%XGGl֜[1ɩY1,[7 GEcYcw~qg 0VIi1Y緤8BK#I8fw3*.߄gf%׎aW^frQvR{4 떡'|8֙(D[Z<,.9_zcnDOڷCG_6[\yuU~ozQijl[,Hc?T_^z?DРAH6t맆Pl+"}Lǟsb?T;w `#;uBA,_Qgp6oիWu_}΃>lM3[?.8_Ч~~;wYt. *khw ߧ8 ?ޖ:6f0 P)yT3:~c_ % ;f$&(;YEbtU< Ձuyl)$ܛWz 79 y Eny0X1K?|>TWT^gw'F Erx@{TZ¦%6OrŒKjwqֽ܌8FN#\6MO2+s(Z=հz爾zE?,qRH2r8s=F4C}@^n)``BҺ0 r!eǹh qU(kI랍W" %^m_/q藣B)" wL)[8)Ú6 s u+YIpЇWwvhHA(Yf厥&E' U.Ha;]ecYv̀?~DoPOH#Ƣ$# OX7ovq#kQŷr<-g7+F'^/`V;Rngm]۲m:R~Yt_+\v%rm5ۋ6%gP+Zu|@^g,3&jp8~ eeg_#.Rx1v^0ʓ$i}$wXW C9&^Nr<Ч+C&0Ke`:lG;/ٍpEvyui<ϭ9?4`9n4YQAݪqR*Kaq+XCE=)\Ԭ%'HAnXԒ)h*swj%[tTwCi1TATGD@ZM%%:j;<]U]Oh&}E<7Yܶ5ҲIAnZ0TC{8sO`9F}.QH;z<Q|OVrIwO*,\׼[<6Z ]98:Us  g!(\ƍ[b_o7WL!=C[KR4]Nmvemhfs,\A`_Li{ON.D9#.q˃''{m꙯O~z])4ZzO}+W3<~W/w<ٳX7koJLD/ga2lQTB*p \I(8 t RAI[|1+~fA$4]X ~)5I;U+fݯ++>-V!:W\/\@q%{Zlٵ[>K B}dRpoˈ!k7+& ZR9Ҟ_tS*Y/[npRiI5N pzؙ`?ݦx[lyLIA\^6 (lu| R5U-5+=rc kXMp52l ȉINUCc˾CGʊZϡ?g9f*xOn?hQBQ޼M`Eʾ?yr+p+W1ah|``OҸ[UN T"ɇVlXSZR޶T>#_<]1 n*p0n먂s?/SPqeW<%P!Bs@NIH ] ޮrTv2q^հnM7D`N>Z2l]C> z|;.#V [O筱1$&ɒ~h];D|2%"dvA̶+e5oK~'s#)XWEUQ 2[stJINe&Y}#:f-qY^]_9 d>jC!59gґl@5*U]oTU>?uXee/b2waT;E"Ӌ R|:iuy*msn=6 8!$%r80JfR1 0fM *[8%|L~%S;gI/HC$:*$DKxg{ݗ'?yQB{P-1l пhg>?:j l'kI o g1w q\O^գVN]y^(aQ6'' u" (JCj)֛SܤH mL~g"QI[8;z)ؑE}y $$ˉ3gߜ BP(^(nd6n2QU}7fA= iH2;ٻеԣϷrB{Ifwz$MnxBӈ* zNso<>8|C eXҺJm2;Jh< ѽTaȋ3J-CL2YS`1<]W+QT4^Y^u[4i2^P(>.hgv}tU5%`=hciJʂ"];0A^n`Bq'{(zX$ j>N[ܚ-A1C2̏,^\5 3vR_;Swʒe2-# Lط^xt#|ƹ 8brɔM_V}@mE:sŠέ޼%/a WqO`EANǦDdni"]üW⼠4F1hC>͜Fg Ai)F#낹SW0QpR$Ӻ9%>nӼc1ԯ,'K/^h$/cb+: tyOOx1Uǖ (!û-I4G/N*V*]ZܘwyY z; kze8M*);*i6́VzMoN;.Sj_Z,]י8t/d>7"|5{|<ԤG 4S2@P=\wc*Pq:"@`t BXb_,^p鋠'AQhu5B1^feW1Tq۶9wcs %{VF7r. -{ÞWُͩ)1*X$b#UhZIDƄdNNyP(ydhJr ^Pʑm˿|bj^,LkG!y/PcL~5CMp1́e4?4'KA#SԁZ'<>5d4o骧e@5UIYկVF3jՄi6j9UWWAu9zұ8Ε'd1~ɝG֦^.L|zZa/]82&㰴?Mۉ)+YnIzF)őpJU\OeXsr[]4CV[ 4ZNoBe%Ky,g,_)%iՁX?bEQYZv}p{DdJiA3D8:㴴`Wk׮D\~+ܥ׵[]\8&BE[߿UbuJu gޞ݇>m[]b l7%lwLf[ YM0UEjonW}kc}F`vϡT}xp}0*<< 1[͖vÅ?g_[mr5ɛYTb"m!bx0$)7>7 0-^_Z2?[ e]B?'%'5^eѣ9LvXEt$򔥝~֑ -/A &>^(*qR=_$z]{ ߄ˁHWRB;|+7wg杇A {{޺ņ֤*E9>Vu0_dmx-n$Dsy  ~nnhҼg _I=T͎Sˤ9 @wAZm{ ց|˪xY59g7Q og=E |zp}@asdK,_~81[ke{x9 '0 ̾hҨz zloYr`%dXp^㴋P֦ Eg틠?Y8حCE8'DᩃO|ZŧUx">UIbFdP3L8ͦI%]O*t1)(bD3m5 G-&AZQM'I~DB^KFHO@'LA~%d>]#VE'+Rf E)7| a|(̻gզ>DKs 'FD *ФABJwFY<Jo$^>PK@I,ilHȆv·"vUd?fHj06n`(r--`{ ԃ=YD?!TH+VO O/KYU]!FB)&b5},Fy?/C%%>S\TcF90gYgf5~['/O_6iD-;2gqz b&OEXS>L}9YdWE_N} G @_FZ)AO'OPm||꧖W2F<D ogoʟUrA#!33"xMܓ:s L!v%ۓeWj 湆l+H;'Q⶜^298!lu~kj[WquN*vr}5$ 6iiZ̋ ŋMŋ,iɒ]W U &n&\s4g \ƠɱMűغX4VQtUg+ &Y(hdu3~u`:LnؚnU(ܕG)j\ q #z@*hsqW@C sNa +jC[di"]r6:=`c#3{ e"iFBъF5&!ۘB@o/!=YCDr +BUpjU^=!#\{jA[bYVbsl,2fck6flȘwB/$kIFg\!Aև5BYÄbHȸ管 .9"Td"H!!^܅"'P8 Ɵ6Dyd;bsc H#3R2ZԀq=q5sEEcG/Rnc|*>ˉ5TMi:YAlZqݷ/Zrfn_,mHvOB ՋJVQ“'oSZa=#\ #іQ3֘v=op-OܒVm7F 1gP)775b7mL_Qm?CKS 4/2 h  ȣ4a#2NL`꺁Mv]=P4`5Njg0o?׎ڽ싽=3Um߫ kȸqb 3^Wi Eq4RH2y,3š׵acZϜ6۴v۸Alٸ.%jmzy;6ŴQL,&l]no,[*mğmiK4O[ j Lm3߱pƖ`s|R"5'EP|R&5'P|R*5'P3Un j kF֎G!XKr_oXfgMa(|Muǖ^=]U!7'_DSєxº)cʂ!HJT°aU¦! mK]Y{q>?ivc2]kޤ!4z|^&]6 Ui'񪎠 r*w}`İJCJ]# :Q8p˽Q$Y6Ux@=zkկy G8&[<4@=T $ڞXpUC]2Hz,M zM@fzqx,d 555ITIHAjfj bֆ?~הX(x 3gW̅'Uu˛yϲ I-0k5׬Nj$!iNj9BUx{4Hܥj׸uvKuW}6mq}\2 +lͬrۮ-WNd=vUԺ&j]zל f?s?c_?C-vo{G?_WYغ]QLk:JdmP/,`0Xc2Ħh ocwK(DGBlBPnDׇ0ڬ\CQC05K頎@Kn@s搠I`>auEˉϰ$e,뻔G˷`~O~m8$!a@ c8 ,=c g,W0%,fr)F,ǀ3_>Q6alzZ:c4 NNc>wwrƝ<|$+Vt/@WS^2zMO8Od..f-K* "X_ a71Ōl3#;'3rٍ'3TƕTF&u101# LGD~$٧Sd>;#ט 㦃?l_Rnnn΄L2afA";vB>GDĴe:=>J$b}۹R6 '>W' &̏k`<ˆn‰(ϙk}zAWb\CꕴZK;J06]Aj_>6b5Я(;_מdRH)GJ-h>v*lXx[7ƽrGՙS= #hh(`AC.}6n<\csv\HRhOmۍ-2 ZaE]pXWDDv-Jɣ+kqm/=f-bsBOjr=)]v hx5@s`Vi*7GkxExnLvZ0RE\-|Xsu2`, R| ֯N{S;-xf&qODdh  IGBV+~$ D@*t]ĉ˻Hn{  OxT2XO-ԓ &?*J!l"asAs"d "YJtVY:FdacΞj9<=KFuĪWXŗ[EYڤ.f.Q6> h!9JQE(/5|HYbvvGʰ~"3M΅KC"7Dn \^IfBr~iIN/-i>},IżJ]#sS~! !xp \蠄S`x$E#W4g-H"sکUކ[xVOƠ(ʁ-\q.a1cVAf DJBt.d ^8Jư]ϚWTu}x? LS1#ȠϘSdPgL?N `h}d'@F[y@i 6f-^c1&mnC)Rȣo-SPOA=5oa&AK)]ptoN{∥T=3t0ޖZcbVnTa,9mYMG92"BdC[|qlala6# 8WOc8@g[til?I}Tk`P_#N됔42ȔQ(_?slekK7-ȶts|y/Gk(S%+0Nk0.'\m> 6{NMbleȌ͌omؾ,.`ShQOm㷜 ~>~ZMU! /#z=DXDma* LU~/Z.qQxjPr PrPQI;l-lHC\L =c\ӂF`5>CJ萕6گ[;肳lz{%8iq3;q2w[p_U+9o伱Su6-dZK&ф%l}+R)kt˕μʓ( Ly!B${1o}gX6?ܸ~S 4|k&?y\0k~[0mۤvF"%s4< 錾]8GCWY=iǔOW lפ|8qU }x7\]6}xxQxfZ~~T bbOퟜ]xft`ip;,Nirh|_A4sz @}v.ļ{}I)'(/*c!<1 /4Jd# w1N$-㤮k`4m8 y/Ԣd ;i8 UkW-D^U$Y<ջW0H_w]&0lfg}mݑ[kk2u5h7:Z[& H]_mwZMZk}-Pjgs}- e`Z(}qS `V+I)Yw0NaIQYJf) 32+W +Yg -|3,I *g~i?u`"xˋMIܞQXMeq I ~7pYN)b@̿_|i0oG3nߍnhbB#zE@;_5,4m O6y\?- iЪ%CZ E ˒OxPxtcO+YN](Ipx 9Г(:6AAG^}wS >En|F|2G-DZXIhyȒPYC ?ɰ݂YD0i[9ێ̎cR={β(=NnHKjiY6fqDg7$} Q<~>ndϯ{X)k`_KΚm07L4STf0%%PA\q1c9Jh0JF40d)|,AқC%2X&a*TX-v(]I1u%3 '2DaMcƋb6@PL A~BHdc%c R}2!+ жjYtK]9|L~,tKv_d\݇wȞ_E,K_Q]Nb"4#Ɣ*DO6qL ЙySiIUbץhN FwpkcZ=$4fy3V@\uj4G 8$}TGG82I.G6/ 9D}$OQ$XPjZki([+&020KIdUm<tNMZHT]-s eX!0YRX .$6YX9G_"I 1&,] Xh5=xY{rg0iJ=T50LeVE goYx,QMbcEg"G .}5=]zu>WˁGdY \`2 L{rPBw-Wz,C0ǃ 2 ++7)0+7`ׯNvX8I5 '$0?ar͒;~EnՎfm<ّz {ZpLw7Qq~!"JF_a! ь ̤4We>B 8f(ng&z%W4Vs_բJ8alԈHPtxDTfH n3vŦπ|d৺RHHcKVUB`J q\SZq#!Jm)a䣔 Qpw<*+ΪHZ5cfyi$d:rBiPgi~}P=,"zc-[,#|NkLYC_6Яˋ jj&Ϊ-9K>NQJfP2'(}d0?'|@f F 4깻QһmP:lɱV{;}BkC|6vVE(s1"!ۍ(O`8tZ{,.@<# ]V"J!#fe&UQsR$qҊ_HTg~&iO#֯G&("r|ʠi|J]@/ ݣRȯE{H̟k*0G-m,BɍE{)^TE[F@9bԍj)lO}V-"aضZ\OWUZ}ߝ$&}5X,OAve,ltX~֘]{qgYż,zvJ03դ4`]@djR8?F_gcY`B;'A6H*kEs͡k'sز҇ރ+տ7@!Vz sx/"Lg?=)`Pk#?xGHuoOﰏ*sFԯ-oVȋg u1^ܘzLudgN\-BW!1Rjw Ja bkVsVm3og\ZnwjnPwwh'zݞ (z1Y_;RJS=bD6.B!Dsߌ鯁ƗyĽ@S >yǡX'c{z XސPIPQ<ɳ3(; @>l3 Tz/Uf7ܶZ´& a1h=]^W`$=;|iw݂6ה A< 9,A@J=65 A';T)DS; (7ATAH0/?TA%$@ L?i^ $>Sb1j4KQP7뻐vpTb͊bn*"Um] g/f\0~D.g#}i v ^]}cSicG`U@ǟPP!IRYY} Kc)$=aCWvǨWB1r&Od_v|IfErnyCȋ IXiLyTrd=7TR6zK9_{ިltՁPv`4R.k6:KD!7M>!Hi(Q5@{哗BЭ'4"”H%[~{LbzZۭrښ᷷K9mѵvg9V[~{s_/W %mH_ʥ gjKA!vX7[B[4͑@_A0x:k֐Sgv;庶700:*#!ʕQ2VXF=҂ke64 pwFtj3 cqqwcptb8 ࡋ5_(mX&ysϞ~ʍe'tڞY NYg6Z6gExk-UN T4,l-GE*uP]"GQG %ɧIr;)U:qgMu_i`{l.hh!bD+oIr蹊>ӲgܿMOe?&na.D.ʍ[h ,>Haݗ xKhj$v{Mgw㽮Ae/l +u&(|b^q IUQ8MXfAD\BbdwՂaMC >\]el{ʰ|_u#)XZUBs4TVLpEe:ɂ~{Zq=TjbFZ; UY|&jeX=j`pN״Aa/0 <}wf[LLTN}q#Z.>mW*u.'Ze7פ\Zn6s.ʄr驡o*0e0mu_ޒYvF`xq-~jyں{'i!|A߱rD߆@!sbOx2za˂؇Ŷ,u b\{owB뢀nrg dJ}ô(c10'9ko1d` Bp_)̓/?iovd1~T}/bX͛^Ir'u/בj-.o/Ys!UFatH2 j\t y0X09zȌUT w~Dg;y.{FUѴm!^@Nތ 0>82PfRO)tKg^[:II"mu[*h uYPUk󽷨+~B9R5E9+l:ɠy3BTeo"Xqz|Ζmŭ@,JE吧pq[֤ŋBC>>êSjp>]~̗M]COOXW)eτҸNi/L/YI|Rxa؍ _cgޥvK3}2{Ob\P1} ,ii?BĞPw7Ӭ&|d]JsxR-[2Nt d6фc'L +>5 #ÊxIGKc6A3` bfqm@ rU,J@^,TxWً"[H(lcقnF lZbŸ UMBӼށu]F6y鸱*KW(,ЊpJ8[A+vH(d_G Ӄ ͚Kf<vh"WYd)oru^is`bJ3~ 0HNwI4oZ`x^q bvX;ˈ# 3DI(R˚@ݤQև.F#f5_k,TZ2Ǘ{i[Z908D# Se3Eo^ Lj1xfVF_@skP4RM\ fی;| :Wգ{;=*Gegu(EifUw'GgG-r JPHEKНOOl4&u~:x=kvj65NOpWșUin|f`h汎:We3?dHzG9Eafsg'a9%{e4VR&c9*\HsH<7Hʟh}Li=pb<.)嵡Pj3^QS=j){-+KLH 9MVxa*$u5Q_JOYBDEd[jGo.hTx!UHeudXI߭հ2,Nr=M&Nf##VjB`*-Fc=YֹDķh]Á`鬺a7pƩF<"˨ǎGAQ~˱Uj#1,T9OkAւaW zb>Ԫfg{kցVmZփR/Xg}wH{=oJ,Qu±\vW00ׯRYsCgqP4(|GAf]ʧt)]lxe jCn䈓Q>8+?]=&ƛ$.u8Dy{zy{,d04¬gp݇ˏ'Nyvl'[ tZL:w;;} axOvI(ѝ3wg-^)X_>rC ÿӑ~Rj}:/q)kGa 2g0' ϛ1jH^{zJ(daGR(,"hP&Γ*#nwv 16E92{+|`ˌaS0lET\@2d-A E) ";5/a؊ ^?--P8 ); y4Rq84A0ŭ c"*T#~vtrF4czz|$j 7T%h/ѻă7 ގ'jp3]߽ cs' BБ)\i6X|y]Ӝǒi~ Z8SНcd]᠇`~S"(5&rV\2qDaL`ؓ|aG~R;Ϡ1? a`~҆KyGLAoIqUr=Ib Dp5߄GMqNdC|t'T P{X%=e]{pfC40/Wr>*\3GC]TT60\`sVfiZe:A QV޻̶S3,N1tG{p1L I 1hwv҈yipXBo^9ǬKht6u7T7sU>gd&A -U!&Cm[$a$AYUgӘ@K.JI.}/-"mYB@a{~E%\LSVl5,A&ZFw9UC@E,t>b aeл`3U I|r:6 $ eG<$bO`\IJ8%'ajTyDyPdbd'F2j<7@a"lNDyq43EH|SDH.&}ݲDV7?_hR'&lyxŪt A'=b,;-@:+@y;I8\^Y_-%4qBW"y=)\ɱs~ $㊞ᇓEj{wX9Kbڢ0]yh%<}y\"p|=[+Yh;_.h6N; jvX*#ĝ|hvP|*fL,7VP7tDb:8듃`(3g;0Ѿ t}SV:oݪ{,xpec OY-+saQWP.nO [-ވmQ RQZ CB_`N)yrr48^|N xQ|n薟Σi]<0y:9DK,Ȣ:_W>>g^aM8ÝAwDWa>5P Ga _*tf=SnH"Zj{!ׂ;D`8GJd/S"X^|!R/^ٌ*rza]B3_VEjK :es~8X,\im\-9xʃRTmH9p]D\ w1},4ǵ 4.’'厲kʒ:qY$,s8S)pioZP b+Wqg|%>;/_θX(P*TNiJƧ(gB-2;>T ˀGzCDg͂Ô]yP -?A XpTZt0s:qE˙k ~_ Z|D$2Lq)n1]y'GIvT>G@: REL gmnP{C`Aj6W"._oT>&]%zwRKr\5`Qmn΋Qx2O[|REq%|z-4= AǏ=?&2tab~\Ŝ3INs)m*BU=r0kÒ)SV]Bl ʂtVP(Fd1 K|*x~@#`dc16VЧwR9+ P u  tytGiD²]5A/#%YgP9w'l\`8F&Nj{{*w0jQIb|6lFh#rJuwA ch߱]&f~=R,GyXspѱX?x&w߿R@|xow+kYCc=Vl*?{ӗ1O.~?X OuNN>BS@_;S8fɔ=oU 㷏}"N_{Sy; ӊT~M[O]SgG$oUwc.NlGއ?T=Kn':dIxk|<&Џӊ/{ɇCك*i>|<U?*dHߟCgc_dFBtAcLF(?~GPq|,?z AD>țg'hf˖ 60+|F PèQ '-"{)%eҥa.w><X"K%y Q5ҩ=H܃,D҅l~|bP1[w_ABdN rAry'm#.om8;0+A2| p8B<ٽS.F|KeݩLGTmP ,uX[*mԘ+dEUHD'Dp._`hIbe'蹄[Xwzi~ϔ.sL5b, qщلnf}~Ǿ[b~}މfh]o.VvwǼ&-ک슱0] T@L4Ei_W4%ˬl/^/-=btyRD x~"3 c;;kR[S&* S& ӉVTs+lܩuDJxwCX4-D3@.ҷu*7Rӟ4 EV~`Gv 'U5/U Aʖm{_J}b>ME P?Y(` )~^5|j;=BYr޺|WdX#m֧3*~v4aʝ{ 937u-L1Ϭ뵚ƧeZx@$s>&)o6C++T2AW$A~,A*pi<Fk{:ɵ |0=QoDM %$J^dӱ j>S,`rM!p) 0 d!0!%| #C$,}NBH$D0e%jC* BI8K='C-(cy .} XJ;N*V%+1T|Uǻ0~-/RKc+]K @t}$mnhZA}Pjނ9/۳V&sָXj]^|"?a~7vv\~@YN2wɻ⫏[j3GRTcqFEivPqf. 1 ksl=Beݠ9VjgH#ُ2Sf|Exaz? vk'7`>/W+9]'B'4O@"d&hMoLhYI}BxJ|+IX~A!&#`*'iKu9:D3XBt9r7U.[jjS-gڮizB^@-/s*{ Kw^AJ+Alua.#}h҄vD23I2na_Sߢ_Jzŋp8?*"Z mDt 4 D8`\|r"\-UY֯xEV S,0R3z'Wy\mu6W7ۛ[\koolonwV7ֶ!;jvs{mscmsZkooBjol77ͭj;jbVkjsccku{ Yln1mYאַvֶZۭͨ7:V@v0PZYlZ5 !jlH:[X[kov:Qcol5Vk{} Z_[[ݎz@v{ucm!M}mӄVm ϫjkCЈvg%2цlBj$ˏ?x؄6u <* lsc|Q<3vϮ-Ur4\T NڬѡAU+]ڠ1ԁ.P5I ڸ1jUl]ԱRhvc'c\^Gfж(ջf{/C:5jH᧫Y]0i'iJ  ' փӟ,hPv_W' ޗɬ^*gUΟ&z'-%I5(zFuQ2`rWA?h՗!hu{hw;jN~UpU)k0Dj<1' p8 KZU^CNev4Cw8&8XM~_(?i7\P)":,Rq -yV\b3p8RlkPBs{ 8B*y=J ed!M$C#i ;~țLq\Uû^JǝIOw`=xUaBpF<\ $a#ypBT0mˎF>&Tm9Jq>Zyi /!(LG n)OjkVm[I(ݿBCIM!=g.mv=shV+-$J°~3^23J3{smS 2OlifJWפ];*Fp q0VCwl8O"=Oa4VFƊ2Ijjq0G{9ļl|>fy՚ZU`3`8fzI˧`R0aըqIui$FiD:.6q!z !OЗ"ņp*hVt5B^-_sHBP s"pH8Y?L- N AXELPǞ4u8sC"Vtb+}]BCTQ]A;esR +V (jC-o -4$*δotg5SaEja?ayXټH}٠>^j~ܗ6X{ĿNh2;ޤ_A|4FíS(F$0SnQ}B)vղ3yn-[9uMRN~y'_D/u|y5`JNoo9N|!UxOtYeaIW^3\yYBVa"Uq2BbU&(kG0a###dj 3FC*qVGC2PDDi^iR74"g;㳺BW 5gp%R(&h ֖zL` qMڬ%6kTg vfvj; cX N'by*RPk!y [g+l6EK TIPJ׈J)TUW4=̘f4ySīϯ|:MMr :&:A} YUW.һoip a1}fҬo] hSh0j*K[:&f"}%#Ik:u4潫W%~WVWNKWGr?&~B;ht.5Zg2A cm7 պ!16ZN=n9һ77uGYț3&˶Pc54-L*~7)e~.m ST~59r3oVqYI&g^j3!f8v}UWF!R#pt)QPTX01byJOެGMeMȸw9%+l4oZ\Y1I >{s]P k(~ 0O绻a6'?J8 eh}Cv=;2%jh}`1$Vϩhy&}h_7>P6P9 ƽ3 0ÃvY?g?Ra&K(jst\E~j19j0 R$G|ҳIUztY֏0IR^ gƼQ8lYPgө+_=wf =jkvx+>?2 lQR} 2IãrjD8-Rx7yD`n,CL7s~ =\TR`V` a6JޱsO⯻su9 ?&Wg31IȝpZ~GImȰ?y ~}Hҟvv6A>V՟dz$I`; ef+ ŶLۨ='Tw\=! 8z)nu2/>)? 67atd~cz_,EDUUlkubW (pOB "'8>!i}{n.cjNN|4r zj!R7k$HRM3wGp˹7^^fMF is5Y0.b)iDzGh!@̡c[c?D*yPIU7!":f(u0P;Y]0"K+ZPF^uwvl. :֞UbVbăPmo=g/9{ڪ<\YP"'.ZV}O|g<:D1DKlm-߻ÿ+gAcdw~S7r[Y=?r[ Zd(dy(|#$RKrW&tF~86RsܵQP@"agp6sF5>Qr?jI@Q05vXww:+DcO5"*FioF!d(r*5 L l!ca,=}G9t" i>=tQqgLNqox#"0{>BLc(.FinS%g'R5'i :0:X鰶|`hi?IL֧b8k9Gc{iOjI^2h1˯^d *Ԟ7.AJJ!%m@$" ?.oϣfk niĿkxaLXi쵁7nl˖h7񴕣Xp+6M[GKew(!l0Mu71/ kk:2r !so:u-p3%xT*=oϋa5{F9@z8UicXX0ZݖIR=bo*kAf86G@Z2T(%N̒4TQʼnro!dexQDS9nj*V&R.[c3US.Fwe~KN[@ME9gDTX$ۨZ6a8!JTM^c^J/ B1u5b8+\ D2?_5mMYK[sjVMd]̗B`eH-5qz{p^׹}{b[-m?gZ5 Ѱ\ZBM k(?)GW~tpҥjۢb19 /I3鈒u/'$uP5P.lpɛ~6C6 ب_ǖ _؉^UۏH_~p%ىE~u sϱϟU 8K{{H> Ux[kp]}%uힲЂno}V{"+ #b4q'H d5rܸU!B~tuxl: {ncG }I_Ԝ7^N]\_aSfH+8dԕgtG{sOAT`O9&A77QOfkZy.4B. 6]a!=d?B(vr1~:lt7Fl9e} m 6CF~]V%RT@Gb?_jO܆Pý:L 4r˗ =|콂ya@~0 ?S/p;Ao-ě[ KR0H9p[ɤxYN~UAeֶ 4~7m,ވ^lŻPM}ĩlD<$ elSamk]$)\Ŀ&#=9d.ؚ('A3ٞ!P|8fN-Z%[: eTOd 8ys0daFsNj(Sз7s~u!s?-'B#,0~,ng9ko:2V.iU_/X#:䙳]jp&Q$E~eޭWN[#?,_6NŧӎM~?xcBnfС[>&)&,E(6*AR3(1epoVV\o@N¶ӉVsh1+p12~VV['C7 VVe{ӼF*ߨZz^3KZ ]Mm:?>w:o5<2Łioi9pVuZ i=s}R׊C̅hEG25g7W0= lUm2$Ob.gx 㜵w#lOfoŰ襡]VJ砲][|5˝rZA} t~ (_XHӝ뭸 oRLkosp TopN (GASRJqEHZ:Ï+Z]+?"aw|ty tZ~-ej%"}`}1Tl) |ۃ4AT:)\-W!-F]ڞz6d}ՒtB:SdG GVs, wPW\ۜr' [Dyto9@XIp( yJA;CɜzeY7RGαXd~?76L))˧xԒ}_) vxa@yq-P!ؐ!&Is'忏rxy T]؁[Rqkwd :OTyϼvG*UX8y)%BK PSΏR?/,SK2[H/:!x`w;[&>TRپ!2ki%o%eP0(**ľ.XHVjV7Jn|)u[7b&=9[ڭmeR+ia\bh}CzBb<O,SQ4$0 V~cU}["YN`(7O#_bqʛ#c4s̠P12W#VJY \Wh8/gE.~*HpHvҥ.j@*BvozQu|UL՛L#CZ݆@m-Tw 4]D1瘘pp O*&BJ2l˷;yho/wnjq=jt}5,Lzaqܹԛ|}ECzl|Zћ>4+j($/?]cv55&O}"Ab z=I*A8Ŏ̫{rt{u6 .?me(5[6F޵mMHj?:@1:-:P F-Bz=5 3oB^0Φy=/1y.%y+ {?{' ļ1i.F.W*g7vaE8WDx;: a=vl> f%ōn^_@WzV/NIBOKI89[ǿ,!2!.JM&"Ғs~ _ uDT0In #MdP >6qfI`N:Fr TtՅϵEK 6yP44R+|q񮍽)JgQqfY05pRJe* m?GȔ5+ۂ1`<d*J@udʍ"{feΩƂ&ƶr2[ = [}$l%<``w>xS?]Y[OS. h)B%zc8qbz!MI+U,UJ[Ns N:%+6*-S|)cTM]]eOQs!D5KR#X̠GZ8*rW/WkfNE+/Fd,"2e*6t0W_e>6eȐ`sJtv5Rw0ͳFӂ s@%Frݭ`gGKNͰ6 2|Jjl A sl$4Tk+>?\9C"#TUwlo0lq |CIϨߤ-`8^ǣ_z޹Pw*΢(WJb/pM.̾0-[iŎ2r^ qxxͽcG)ށO;]GR}(9v1uҋS D1u҈DvG߈狣ۻGGo?Cy|>Lҷ[{vw6=eB 79=;:v"7uaE‰BuzIa<2'غDO.zOx1}rɥ{ps8 ê[^cyT=͋Uws"B7C~\~ߔZO{H-Y|]Rc&[;<:bjw?mX.\QՐ*-I 鬞^?8*xFmq!yڛ68## ;Vh!P ǢƝc!m?Dqݶ Hj S@m-E y=w %{?f?o9.Nj I8VJC + -i[ޒ'8Zu3Z'O OCڬrÕ~>rH,]#,  '>C_ӷD0O67qB]hxi  Ue?^_ETՕ$T gj|zD\!<ɣTRA ZWs|7U]k䅭i5fEҰ_ɋ`rӣ. K^*_xVr&>DJB0_uS,I:O~r|#6ڇ ጖^ݻcoVJdG,Lt]A&IOMKؐ4et]?8i ݫ;LM} kq8$]BZm^v;h.|P <0wh-Nn4f7m{}{28Πڍߴ2./v=<ؾT+ߗg~7(u7<'Ds5DI >H< pqXPKOAhV24sg4PKpa7sZu) rpmKG梟Qv"?f?rUZ]-Q'.TV#/ ⑽ٸ5A}cE]Z7^ID1OTZu¦W9H<\9=ӿ F**=‘p߻7ڹosL|Gl!g"W$SyZu5;|*ġkѣ1k)bcی.ʞOʪy|{NwPcݭP̕Z^E'\IZxj&ͻ;:o#BF(hn#{g\k!)%au DHf`OYYN<~զUW͊iPiFz>h*)]#(w&[Wvrıcwmɛ|59Fӂ)jkc.7d Ut"oNf+ =@REoyt،&ƺoH "$yjZܰĽr6* .+ p"7BxXإI.o|__lLJv7`[ ^:w38lf W<D#ytΡi/Po3 tOU6.=um{C&h%<>ɺS=u5zC2ZO`@;4 `U|P";;[R$ZW\;2No̎\}NPܻ 77`{|uUuQ1 ѣn".КֱS~ n7̧gsٟDp!,%c564i6a ͤ'P55uh&ã_P4o 0r,}UP]=j+Yծ&W%~YU4w+km(c[5QnBnNklE=aK.n1:;6Іj'(J$BY\*4^ j6ϧG98[!7~'}螪ͨښj(|.|fYiu[.AxŃǓY:]|h!}S #z2$|/|Rp0CÔ eLmx>4'Ӽ:4!2`i(mJgƐD_) KFq=~oC1Mз(R5m 2!j(. &$cL5Bn;/Íh7ƠC5MWզaCdP}\CXؐf֓$e UA>L|!؛5?7re 첓zP V U` .)% ps0N/WNV, W}-)q=;NPMMqZ fe^\\ԯgP )g2C55t FwH~FnFnR0|TmēYBGܦA**/u*F#Ō#% GKv4 {FhT5M~IV\B8. d0to/I7|^]Bn\K2<_ Mk)C%)c]#h4f|_JS0t}0!Tòٴ7*=倁\.נai?{MvْYCVjߴ^x_LZ~۠!72-kZ{\X^.]fk/k~ş~%XR3&[3_`6 *L}jOs53yKKEUN^e戞!%gcw %啈7YF/< fז.޼_FSzջ}"~}vGxa'%jo0HIzF4Ti\TS.$b.yo0*Qw`;ܡ Ow\ypOj㞒51}W0xS0gkʪE{E9COaі;P&lw9]4~h{8.UcvGa8D"K*EbW"ܳ~Bݐ0}s +fŒ_R R!`T"MJdG=$ٗb9_՞x:J-8GsuX9j hl l8Vƣ<ѧ02λVtj %t?Q2S8T)&g}O uSa(4[|86&}/:yu3l8䷊#3#kRw~Rh5oLo#a+,z=v[5@pC}d>KCU/mF.md4tEqQj괍]jĭ(f) NL+ӥx,&M(ª6FcV:&)QV:8çkk9D߆}iwtxJbb\@#C)q'  n[4ʶۉbsRQf;huz#زH(?xM84G$Aej-i?_ڇX7mSm t qIc=\C 'Wrr[:"C)48AVxH9MFPAE/G"!\2GG@._dYElMϸJ!iUӑ.mZ2U88?Sp uR +éQ }`msC޴t/szF(mE{7}E"t[လ6z#3dؗ*NI Z6_h,i1ۍ[sT8i݆ZBvT67m[g@ay5MtMo\ZӔ9+cV ,c\ك#ܽ!.ʡzk>s[ Lᣥ"Sha(H:Mkb[m8'(}iuP[|x⇖錤T֦ZN6yvz |RP@yh d!J 9X %#lsF(jWC3vРC!<|hgD[/q[S#G[m%$kYwgF:_)m-wB!Pv[A\;Beu;W@Ql|)N>]sʨP' 6>T(^Fљ˘7ELTr1pTs0<Ҽ5H+l${'Vbep~xĘ|ANhGEBXxn!F4#P_F&g%3Ո{Ogb1C $x5FE7eBM=0/9Yb&JtBnuDDޮ)mkg$Ѽ sE`,\\OsM8MzYFp N-F=X1E,mr92&t2G$ګ޼oS:ݧK!%>L^VO%4v$en۔~}7 OQf8>3;`':ɲ["<:ozZyCivM n;T=A,{^8@!vշ\mܽ69" h'ˢ|魍/Tl&Xr[;6 llp̌ zC0:w'Rvb֣),gg^?|qd@  u.R~ =:<Ҫjgvjǣoqs+3qZ . pV6Е M(E}YiD @;6ʆ~t,%wPEocvxD<(s)},r:N.;-G(drV.hQ\z0+X&O@c͓/oE3+y uO -aA`v҉NB+>re Rñ^Y[Bi\V40}Z!O@}z{[rEfm/5, (Pc5Z;զ[;o PkϘ7oENe#s+^:#]Y-zAOjl jS(@jRآs6Y#)HK4q)Ċ 1Gp]QgsA>r u&<͆&}6OߎhdzwB{r {369M|#ჟ0>Ȱ^6|Fv{NKۼO90R3!5=TG 2ļA+Z ,2H%@?+PӀ(Ծ>sQr5Ѥ7DŽ:", BK4:QC9ze{gaE!_|{x79Q681V0 2sUxXCck#DxnzI+P9kNeQQ"LuZTEYlEEDVG䝇nU g\kfHmOmY]}>(ѥ*zt\]qRg3V*2Ruv^/iUf\bz9?5ȯ$r5nh pߘGfv Զ|ԃh6D{YJ+ZمK[gt"q?bsYg }-gEqՁwZ2Y ߺe0X bՄÅq9AUk*ZΒQ1,4F7%&?:|xP;>NĚL+}a+>0SUJ&ů +rĒtaW)<_`=%kU9:kM ;ř.I-r3*C{;[ZA{z%cNTTj-"2wn8*1rls@nU\82pېlY[:*-2G zN6kA=ŵ.uQ__mm-JF;,"vJ]%pLuவq]_'`Ѥ6up^ 4Wd_lC/pnS$mt>$:%b4<`Fʯn/TvV5(`>@t8 ujHi?5:e;b'Ϯ5/DBp66Cs#@MvzohREyJ'B}'4UqH+U08fdZk> G<h6WA~4Ѳ>V1 h^!zm|l-\kY0Q<8!Яo5j]">2ݨhXjp Q3ٜL҉s 9#Io΂ofԳDg&؂wPփCۙ8At?z;L:60lRru>6Ā)X$ޠgcP1ri~"wN=*ZJ沢szCQ*K o\lpSӾ㣊SKNqB8RjH"?r';~;ˇ / 2;F'/vn_fI{wK3: 0d`);vğzm / }vv,H v#6AT!DO"\_8AxPmow>Dڎ̎@X E'!nAVR@pKpj?Ph[UMю=q'8U쓵:5,Ks]3$UCKbCzsUNUĹIm [4v‘ Q'Z?l7DX5C :ΒMeѣqf+E'L;$٭8%pWgJNד R .MoU>64A@n}>H֭`"5^\FbŸ@ȞDp<_2@ f1HbZR%sw%SkY—zӕ?9{IZe7fש.< T!Gi)&O+a  x<-h>nэU#c ] u6'RDWP5b)MЂsV@Q<T7Ə^7 -V.O1kVM/>׵\v/ESu]NT-,WK# ˲V7qTMvՊ ׌eJt}AYAG..5)HSh)?([Ƿ+N`?MB g=l<Z;;vNu)܈8&uB@Ԑ=#1eMe5a~%Ż }b%rn\-:IKX_SbZ~h8W k@Aѻ; ^9PAB+~+pp0Dѻf ] .y=J͝"7.U¯.8NY\ˤECSצJJ zsti)=(FcLna aM^?/I󗎬%:9 IhM4XbxL+\'#/ 95w[0 ʲ;J;7PVQ2@K5zA'Kkrz&YogV"!*>- C0>'3 \8&~d;E?EpK\rg^| ^"e'J:ru] gMj¹a4J ΦfZ'IoZ9C5, C*pҷR=!, i33;ҊÒ)|Mm|(vl 0yD?ˉ#z_{ԡ]=r"aw1i0s">St}dƳHqZ:#EH N7v#w@!Q?ob-0V jpޑՒA0. D޺7Dȿ6<,2i8803yK)-n,,XP&%ZV_Fm[G}-FwT/j(x+Q Urml*ʌ >u]" x}ũ|H%@<8/[[}L[:QIR9]_ť]b:%ހǜ35 UN6G<2ꏇ\/IBl0^G <^`tK~R N[II}Lq0!< Ywnsһf\aN|(EPѧQILj >$Fyh\sf|G}-.wIkS"Ph< P!&s [u-Q`3fE7=9̶Q.ZsU8ZWll.[&)/T/򲎖_]n mbۧZk@$=GE ?4ǺNRjt-ԼʢG7oصftFZr>[zGT>KٛXhf:[XĢZyn@jnG\\?tfg-|Ux #S>+zދHfcHh[ogwϳ}ȉOq&oIӊ_"GTU}"U',!GBZuv`{#rČkքًh6K/ ~4Ot)6L6U&7]^*7T{|`6LsHnطJ~:yuJ&cVHN#E2(EE]5֢O0%[,Nu||Fգx2.}<´:= .!}ͰRXb\Xt.VD~m6hݪ!>ˠS /L}-ujg'e {h[' 1nGCVkr FrM 봀( H95b'؝kc?8WzP B5"f>mYVWn\d\ѥjW[']nN 01弸}؂ MQ?O4gY5D}BZi> V0u氼[2>O(\u5BqtҾ2["fg &hmmRpsʴnqI‡qJ7 H }s.p˗/qY0/Mq QT єnQn5e^ulK'5^C,U**5K)jp߿_kvE<}ֆljnhM9d )v](Y쫺سygˆjX"a_i329ރм^a<$'>@ lbHvc-,0f5H,Lx\<]r+<{Edk8GLTL5ٳ"қ.KjZ>apO;Nck{yN>>7߾0Qpwƣ k);x kx!Tm~ɴi"ejJb Q ѹtۅ;+( xTm#*vM{?G^ !ZxqJN߯?oNSxh/\KSkofIzdю+O&ee)zPGW,k`&f2)WBܜпQLTĬKW?Ľ5!thHk79ЌHfc2FWؘ{MQ#EP"N.B*h$N}9/ A\%]sZE 13d*1{M[}4KpKU0@=QC1U#j]zWzlBzB ãl_GڹԵLfkZiA9c@ 3E?5LkrJ8|0@"!!M8$;Eݣ̼G3(y*\;Eo8t: }qQv@n&?gpa$B_ZQ>S ?}|Į79&L 8LKNSz6 kC}MvNkEҖD#͗۝.]`vwzow>\Pbcg㽏>ukdNM uZOTnTuWO'@ƴ5@m >7hv;鄩TR1v*LI襯@rFPjhT3IR[j' 7B0떸Dv烄C.qB){ɫW/GRcrWf![)5ԬίF3YQ9<0}eT}l ,BO,U71)M/} (>).I_wJ \C(Cgboz&]>ۜtpꗋ l=L=?Φ Ʌ]:0Ȉ~L@'7loF>Qų+Ӹ*z{;KZӄ񔖺(+h74;~FO4]mVp N*ܑIo7b 9HwNlԦoBF77gH,Qa~m~KϒÿC; so:RCHgRUZ̝h@JhMo}^a*Pqko]6c|^x]_~k-#g._8|*uy՜RF_ED|Cv"q_CT>!>ACul:-[=SoN#AO~(!u3KB-  C-k}ZVqQ%o+jTi[silGg!YKj(9AcoT"ϖi:tVT9!ɖ4o:tH[0IJp']g_ H vE[}@KZqa"Zv v/Zߡ (PQ~.^7s#hf 1iA %F4AU0Fo^&'&Sc?7CͰUbgA;M;~OL/EW] Ԭ w@A8(l^AS#taX-Ӣ@&4|wsqO$)Wvw)YL Ta|RQ!NB˽2fPNc']IY~m>*Oηnӳ{=[˲ʎ1 o&88S4ːv/Cvv)X=oR$אR`&-3<s&}/BǨX3-W t32NηTc+v+ &`YOo[\St?kKJbd2_)4[mLi R?*ئ[J"Svo7fYuklšno+0pH{!8Og@Zrhcԡ-B%ǂsHױ/_O{OXo:x_S"]A; f9HBy8, k#%nzB%IٓɋX|=(d6[ 7շu>,&$16ؒz 6c\zv#лt@7fdL#zZroWp+m\M^MW%`+h ]@E{RNNqlrGMrr?R4,ZNdCkl <1#a<R I`)۔;z9g3`#I`qvdS3 :eQH9q<“d@XQ!ѡ^L#mw)JtWן50"췓ӪLi.>-ށ>a/$, E QXȢĐE) qgx!Kt|o%=xcU(qvjίjRp+#f5okRK(cUm{Dty9,in:)"K?!Jg)Nb#^7:O:#UoG-zDY|"U&.KaZVgr>衡WQS Etg*ϟ90dI+Jɒ`LJk j"Sʆ\K5 s!(|PSڷɛG(^GZX,]O%`&(qƪd6X3N^MHW/h\e03C_i/i=ϲWB EPvE>-UTGu zO0f2վza")ƃ-88LB4M&q06 VeQ:./$\'VA%yt[KK_% '۫~5%SRf%yZrQ%\q[bYp^ >g](/\P@A Q=N?0BZ$Z&ZN{ !BOgJN.JDDAcJɺ^O^K"%J:Y,l1b#˗"ƺB1 hI<0 l&f-_Xtޛ^[ga;Ola]RzFg0m.62 F!:?&6>!駃dm yI99\  o2!0|b ml<1o$=˩$uJxrmPHT?aHl\YƘ5<i>hfeT0HOM~`:Z#jA ~0(+`Wʦ_F58{M/xhr=V vm$6 ZW hcb :);z x KmթNb<p'g"YA.`Ct4b_;қuZPxiɈYv.(сߩQb[%S"@Ҍ0Ԅxi[z$Il-uY^DƥCfnTs Sc~|6 Cpɥa<-Mq!ӫtٮ6Ϧj$uTxi7([lId>e8#id?Q+%+F28z&b-pH?se6p3"fHtCHu{f,:p$^4U9U!%C|&'gmkB"7[G{w{GLJ@ -+AfXDRN xeX!;*9EoNG.;qDÁTlf҄)%yO :VL"Bb9PruE0k&p7 ԔiXm&d=i j ž"M^SF=NUPU>N8&z*Ъ.YvB+%]ju:S 4Rkprd0d}CDፈ9p ľy~M:W;$j`"sXZ}I"D%Tv$Oj[WKBD06#)+" 6={?6r4޽&=]B r#|͍@y2 Vb,do@OOCo URegf~BoߎyI-_W9RKݝ wo[ yTN\2 ªҢn'ٕ/QNj;ۗ,ڵ8Iug5Y$LJjomqgoٿj3 h8 KN9X =%ࢠƈlV@ҟ['NᄰPfw҅ښ<L{/|ȩ.4Ԯ zҘ=x63ziyoipBh Cbzs)vcFX&r{74f]a ESMG(lU#X bZEsӪ^㫪ʾk;C 7ᇚz_r5+F'^!;YOh=ð^jЛr:%ǝ>¹:8 cш5KuvdF"}RU鍊m(i)F얿 TίL޴8mA4]Ě}UWLuzj\8KE# _XS~8;.Ԭ[U wD^HW!l0ȯqttAƤJLB&0|JoZB`c>r`zvѼ BVjbK/ֳ&6 NRGThmxRvLc 'M8'Hx&u2t^W4tu(1 |L7zflyJ߫-Z|zrO/xvOE.xA}Mt)V b41*!+'ċ Od2S6қ]U*/ VmUۧa8p[{h2;l=fLHߛ_aQHTG+4 |ďH:yL5rirxiBPD6s"2"Rc? fG BY8GRXqcݿzgVBSQؿ 6W1p"͑A3WjO1E =gUga9 '8r4:S6v- ݂7ѫ xh1nTkJi8,G\D+lW@d?Ubg*B?M6!IJI_E^_=NѻA~b&ż5HDC1Nd+c``F\!z0WQ*kA> 4<î} ߞ@**^jrd\f1%4;ۻ{;k Տ?7[^>,kķ9(6Tshr.{;F@l̜a~Os׌P'^b7sچQ_BI.ݦ_fp2[cݿɑD믠-Mzo/dWwx-帍@k6 "B#ioϞ3ӅSPH BPD]t^4)/hӟތqb;`S\fv,TTJx޼Ph)ޫ'qJQaI:8 bmHhT T9Kcb!Hא~}OyB3N.(/;ph dq9cV;E-нWj?:,35P>mQAY ߖp,{MEܥꙥq)aiBN*=YBsd&_UjLrDF*T_%ڊAϿt3Q3vfFڋF,jc4}b+Σ<->2a ,Uxְ)W7 ʒ2drSe/vNm7:6 ~xIA^H&>:Tf1(`zF=F@q~^+G|k(-RjK]"unV221BDH)KuM `_g1=ogG8w}̴Q(!c,ȒnLɪ3299zhb|-|4}2Ƌiq+mbᝌiEÛǤqjd1@!՞޽6`s= w39 U  ݄#B'-)o#yQ'rxը:kFIJt([Ssy99l?@ӓ/D+^=tp[)J͓U?l(V0> \:\ߪC-޾wN?ԂF4-,:|ltyos2:YIGzy!tmi™ᮡ`Y6&$6n0RȃigI| rbMʇ> ՘ћ#e arvtHx~sA~0t~6uxүu| (_2H 9< νMT_'z,dUU3 Q3,kS1Wc;e$+}2bC8'Q.ǒS2epIe( * IRlCh*0mGuvǧuO[_qeO0hkn) :jQq(!w\f2J`Okn+2 zDf);[iƂ]]S(}OOUyڛϒ5D85STs)]@̂TY3JЅc!P[eb{p@F1Ӹj1gTT&t:vö6Joeα3%9Xѳ-쮅^֞`tYBQxzwf/Y;Ђ@/ېݹ%R`C_Qj@_zϪ[.fM #2_R~D>@=Qke+:ryZ{cH&/KShEU%͌EN= cEMÿ_󿬼W|/眇}MւK$J{GYNިp"L:f:<hP\7lMu,_Bh95K ~֜"ЧT({eܖ$εr\ mHGWd;+`?p[օT/?§ٓVԾ`nfXfvwwoM[X~ DPf (4 80(X Z5BFZ+ ʒ99 e  be"dbf0"+5w+ `wG{M^ ~v_@AӶzq6~/&ۦ񬨝ϝÓ߀Sb>J{;흓? ;f%f ڭC .NCgNY:+'K*A־kvZf4ɹ~#QaNI(+yZgk2!ʹxڅ'`gytAǟ;秬|Zpsn.C9<>g\K w}>i76X`c=.{|F: O"F8"=b)v<9=?k{-MP7w-g-X!BlR}'/@A4sV/,trH{'zL#j<# .; f՛$kr'5iŵP!Wk)-/%ŷؽh v/Lr$dNC/FGTfxA=,zBٙbx;^Ro_`y,6;tSHs"$[s͛1xY3)ol2{Xb/ր,_Xd6 ݸׁ|FBʋ3߱=Z:7CøXFB? ĵ*yPggYro~KAe\'n(Xw6#1{`?XJ:O;G6;흘Ԡ'6=qƿJsw|D+De 2pM2Nf.X^w؝yOTݸ6q;H/= >6p:7, pڥo4j}qQ7q.*،ixw!ZLa:2?F]0٬oʲP5uk-Zr^`FSUBYr~կZ0%DzŇG̀-0md\UK,̩ˑDO2Jl?A~vrH7e8h> X>u&ѣRBsrQ0qߣ}OS|x$J)e4hP٭P0 [}>vu. rL-%]\TL'pws2SȈOdQ4G\ N)yF{Fň8q4UELQjCz+o9A&ƭSm)sɤ.meAeQYov621!4Qk}浴#ogteI|!࡟z$S!7:c%]5USJl2CYl1.2ee)RT,sW{’@G/G2'NKQ^dSۤ8o(k@:iR88q# ŭfLϑe;(F k$#jbrqIv ,֍4 &I<8PiASEzi p?ۓ` tF#-HQՑ@lji%yp:U\\{TyxfN z-y> "Oő-HM?J,AY|q bJWKV)U\GrN^-(Q9vGA\p SW_%p.vorz3H9'wiWCK~ra0Y cJ2j q~.9h5Ro.=PDkXg .n24-G`Sx$N}iC(;{;M$'⽭,ad.Y]QVj5K)%O ˾}KOz&tj~ѬL(ݧDL=5l.$Kb_ uU\ŝ:Jҷ$7*Ay6T= > _Ƣ'UXQA @ ok:9ch}\4W2,#IBE2B8^;$u0 fyV{\6Đ3t19rݵK IQS0+h j.-rDG%)feI@f"-q>sP'Yt oS_L Q**5,\#4ֈLՀOOo v>4x ˧'*tpm^z%|)"6@k1E?)̣oU /}՛wF+Vw):,H愔mEoW͑O*'o֬K,u_b%},vSR~eGRg3Uw.v%Š=2~F'TfV͈5/g?- HoxqGE%Q=h|mSE]sjߐM'u"71:#3B;0B$'/ED++ AU ̬eМ4`kإc7ѠĬtZy|d0P; BiQؿdYMQ1 nWwTh$*΄2:I^ Aos͐G59-E2ova Xh:F*Lkk3 >R ɣ*~ l>=$F{|W߾+ kt?tbX=ݿ ŞL[n2%SK= AhՈ9Ifk)> N*V5z}E\ɠn0So`sQbF[2-3"ч))ɤO4vPSKk$Dm1k+;nQ1G0֩5DTCoDb` oN !0#ZYvmso&#__Yop?[::~}2up!U|F@Ou-0373%+\V2-4Xx/:,Қn~7*XKh<O@}ɶb߇]FNֹzgCIXs13jWX&;؞6mA}F|{ST{OVm6YZ 8(.ވ<7ogn<ߵ.OJ($)8WͻhԡF̲{l5G9-`~^u~7>Se虳)GxZV^VUk Sˣ"j+\<6^wVs1 :#p;BX]U'T>hZh!\m,')̖#i dh`#"k{g+~Ҳ5ڧ@{GFuĹ#i19煏MHRZy`.C5nwT{Hi"Z?:"1E14 n@5]#X6/Z\2˒@Box繡]csLf-^kh/X7o"߁<]33'rk<D9QAp-š]y<:M|sPNnrc^+{N>aW!;5g۹ӣ=ح* Xo;z8i'#3RށQHWntƻl(]ҊG^UNSTᥬ2qD!&PV06KHIhZAybF&rYB%]=Qw0p2{ VYBv{'gmKDZ)޼vU|Yl 6Ux۽-e8U6hzXY-*m<^U;Dž&ohE~q',8sΗf7#=#@1G+ɿY_5ʿԓ_!PqqLf|^>,8p:94T@U9Ipbh,ltvPCd4m]_ZT)cm)CU25DtU;],ߌ ŻH5^"N|CJ-د9I&UB\oDɿo^H٬;,Ѿ4ӭ67?^pȵnKZ۲hahС/rHtx>Hc?D?m~f/om۾p~Qp?nX{GꡊlGc0.v,_ ϱڈ0K;y/Q2GEtf:ɝLHUD!v}\W)BVE׭B ^bu•r(EGibdOQR}<åKaS-{uߨ -\(J3WW$0RNvL}+v)"^н.`S3 ut̄wG3bjuM<` QS^[`kL$=`hb*bv清 %wE[]j=X*ܻE;LͶB3JVAƲjI| n@XvGBnmܱ@¿ְ|]cq2WKYl&T=N;)f>m\++ZC=QF8cd0Y?'Ӧ6# 5P_~¨䯿iV㢕^Q$wP]n0@ƌޭ8[.3NH].Cgb@<#D3?l6-Rr'—,::9ow_GmHK O:BMvu'P|?*IBhp'1,3\+]!&5_;Jᗺtc-L49iq `bBMp4'SQ.Jr|{LuVQ-0M93W]jäͧj"rO4zL+AAXh(Tp)O`͞ܢ&DWlTjI0fFaw)`}NdK6G4 mQ3AJMEw-Xyn_WH`ev3rAepx٧ALZbܠ'Uul:ɗ&Giة2!KM0A?;^H_0q1]Djc/kD5jFR 1|S_a ?7̿?H^ֲopIon:UoUՃH#+7l)g(s[jlEPf<}ƃ'Bԝȼ:~&sՏ}^ X'8[}[}o.r2L:ԈMcLr e;XWH=!m6Ejp~遭?**W֥_k@)4Tį)}4 $~ OBg fqts|EAr $&vH[6z ;[seRż,+k(2D21Ng+ۺ" QwQl$ex~-V eJw&YWYJkMB!NcW_Jp j.qI8h"tFH1V{r&tSmɠχ~4n6FKsX*l~%I(Q @ m{zu2D*J=7HMJ0y%!MG ef2 xRU0xWN!Cu)R*HHUPDS&jXh Hj^7}0\Jaw.XdS5LV Pp`_ڊȁ|0cB`2ANNwCs`lMwZƂI -H!rPB&ǴwKqjxk S}E&YΚw=<|w4:""o =]ło3eЛ8X{jYu\Nˊp^{s>eKD\"JOK#).sh ߰~N231 O$kWL! bsIU[R6/~{ ̊ ){n/ ؠG_&1#)x$ˀ!Cφ̒_5< Pk9գl{ &g! s\򝋚y VͲJa`Y9t!>%+f!xsfQN2XAkWk2O%"fi%O+g)N,dk4;흳c\9bb /Z}W7 wH ?ի/?u;o7ʗ77#iS?=Uࠣ2̯U_;߬zoPx^􄧢)|iud#^>>хgKLMoz.8j؝~]鲈*JGx{kkQa!)^P4F)fDEAQp)LAAkU [[NP"OnxKw$JyTK5 [ŊkJmUhz]!o3:{LJ';{XM[θ:Hp,DYSj eqB&7|B_3u 1 cXt"%ry2$4RHmc wB-U 'MdX5n=D'^$+”_Z _MUmURuqN&AWi}t:Bg{;b >7O7QeM|dAn**(3ϦlrN3@%-+[gucyA.s22Ѻ~nVNLg)ztqI g8¼'B=g\B3(>c}-e&[ =z!y#~|)f3(O*.5S5CSM=bseJ6AGrZ9<}lp((b:S<82G(W% 8o;U%h'H#q" 1Qk`Qb@TGp%vWt5`=h2SB{h> KϾ%ɄWk8`d =~zS0 DgΤiFD|\xDaF-5,ATlcnڸa[zwVuJ㥟*-V?r\77EC/c t}|Svн`v#=e nIӞja2Js{]7B H3;Z{woU"l"EXy?z/z6tY?EwM"@M/ŝ5F}iOF]LӬQR~@duq-=-+ICZnCrbbKfu$7ج\&nPq -ֳǷʃD*OZ5}?1$` F 8TL,'Pc!UVYixYF?J-3\L/:c,^}A8,!,JY[],QQWEi3ķ$6=JkRCR x6恂e3ZN5U?) NG `ȯȫyZzԈk2Iz0yY=Y{։vIdf͸Y`R Sgbn*rwW@ jَGQ4!EȻB8@#80B&5jC Dƭ2kE#4Ĉ#:DIC^BW"=ԻH~v]TƘƏ@|1Ǝn\`MX kW I2TG: fxMEu0ӓeU] #Kly,b4]z 1c U; g+$P,w÷RX?#9uY{#c_WsONT}ozg_ վGH'YCv x1ׂ`$B^B;#;f2pX|=rp?=j'3$/YJ#[b1h^}!'XPF"ȸOɩbI${d!i26*M[J ; T$^ւ"q X$uI%" ;Y4"TٷV{zZ"Vre 7k CzV2t8w/6, kX@('6gz120vC"IQ΀vp6^u'| u(K"/TmXmqt0a#|+hd;73zAl.Vԙ$czNN}gh=cB6>sqI}r?%$K(@tG3&V6M鸄ȃ1sqHChY0v(=4b^d~6N,D`9vUv8 %Or0Ȑ]/sM%Fz E'0=^up[IGn?%B׷Ig!;^xcw7YUd5 f}z,y`ZG얲a ]]M@SQ`D"dPXޑs]5!YM=vpZ,4eX t> *]CrD*dKA̷ۛ]Ve|tqI!O xƺ`WkvVi)b8d.)XkOvVx0RtmٲK/Z ߶`o:->W-*u5o/h4oCX(?LF}ݛ8)~=woflK %֝y}|oRv?ٯթr^8\%W"-sSM @^k<x ?muB/ AB =UJr<:I' x|\Ith<} 4%[8xL@P޻웷e4?V&bә+jcӄH*ihJZNw`*qniV%^l{)<̮:kR&J"/M ~Vam7e+. Ҿp:*]R&d!M"S)!|D;L-:ehL%]75'CKuk nǭ~zmŒAO9՝`xNf=?WiiiVIӵ/E#c<YB$cįe;#,}ջIt!fH E6YpF`, iʞS( X@:JR {,z().G eHsG ׁ0գ}-^LVK=d.#Z#%? ƖЉfB]2l,N0י?a}ګ5 +Z&/:ÓC-1+k`F2!A𭹌J |59wpw@^;k]tagJYל:]]ĩ2D݀d}6H]>)=ha{"D+&utr3iUlc-=h3HajI+@D̓hVTL}KD8ydD0Y JݞK-}qƾ}QӼΝ=r=haxNlX=^uݾ|MK'g3kBAB>h~9 nhw&N#Zɜ,nji;It#wa1ؙz+su7pDGYP>y)?]K:^nSJpW:V.CÀy@ֵKGE#QX,o?`AF傁+l=ޥ7@'4wA&;/9"땥Ico^衼Mq\9w>s0&C2%q%yc+ r^%E23HYPml WW=RxOAqn+2`vE (CWz˷5׉,dcF=J`g,yaT Ac}Yj)hy% b"E:li!ǎ!)#;JJwZlV/dt;G'P @D˰;MdX{d"`{qܵ$?s8A 1 ]y$,\po}4^%@^~`=H'9<=!2)07>kXJ(POu豯%p=RLe=[sҬ ;PGS/!}l%_\ m" ce#28 @ *S).]ƪRS-Ue<ax!ݎGD#L5C6Fl,cڝvg&I)@ +hO jv`qkä3SpyeQr Sz@|8oT 1Ǣ$2Ƽp ?`!\A:lvǝQyn}iv~m9kf,q}}]*=i?Λgmw i9luYdt:>LS{o/͝/#y>TQn~ȡqp|(;i: !6Q2N|v0I 85UՎ-*ә'~[8lf@@_/y\Yn6ۭ`%^J]eWog2}|"Fq^hs\:Ӄ^ivx†rz\.{K{ZMWrYҝnwt߂x(3ԇoruAMiwq5TEb{i\wn~(pF]֪+dE1/tej[2~U'{6e6sX`USO*Tv۔"e: .TKDx^͇h(Lpc( [c1m 'jȬ)@hoO䬮&bۗkyń@&|2^{L1|0̢FVG7 NߠQGA>6ORR0ϰ{5{<&}IJ3R1gFj@:~ʻHQW?)^*YQMO3iV7t ^#hZV}go0VX6~ח:MV81 ZTZ+ iy[go||[gyFsнJա54bv$oױ 4(xg%3;G;ڳ؎v!X5Buh#<5@D|lJiwETȿIZ}~ mZ>;?<q|Yؽق͔m o uOf}|(+=Q _acg4ucfL'Iv\..B *c`HRqTv3@>>\%jb .#f,g1hm[9;ڴ:`C4Sr3HQLS|S]QGO~v qZ7\3xqP>Ltts=f(%^OW4~n+eJT.xC@S]/ُByx#dݼ$2{v@k9^1y ӗ.01Z-\T#%*tzi GmOO(x̥=JVn>pt\$$U,m8b˶FUQs♉Z`(Z$b?sgq9Ohg?fQvk2%_Av(z޺~tx(^NT>!w?#6ԇp~_^yfeg EqPpdfr7+Zi+Sѳ0 E0 ʯ_[]06L̠cfB,䌜sftقyk ex7AySFޑb&H= 5+',`4tv+bwrG.2Nsj%$[`[v"~.l43TBc+u>MEC|rg' T̹i7 !䘵T+;,f  r1 $8N< ;[JPhaJx( mήčuBE;WQ/P3Ét@b0ٍN_ PD(нiV#z 0vSORY'|^1$M ?7T,??ҏ,~T*}u0\k]zeIkH׉!ݔ~~- |)"~=tT8`V$'tJə0|Co%KΖzף~c X{X! NJ7o(uZ!f>`چf!3t,hCh Q%{2Mz|fpjK@jGxwt.櫎r|_?BҮ0 Ԏ2ΆICUKP)Az (?:(?:(?F@HWrFF)@)?[J Upst̑V@C+bd_gב 5y! ,贯51& xUՉ @#hKLP±lSkՍ:'5N-h]*+c )G\l=ꖟL$G7_AVY}tZëh%pCQGQX׸7FOVUQܯPEF*ڛ*)pSwdvԂ}YTy)C^ |$bjXnZilêZ{z*L3f %,Z#u[;~4O[/̂{;>?7P<sp<8>o0ƨ7Ow>ͺ.plʂN'4{"w&? LJjh5rheU*=dHd@L\⣿bAWԇBVnp4VrO#QHLV3gӇG דÎT Bdӓ"zM˫hDzȼu41MU* G (3z)\ B[gJP;ݰ4m@O@;.2&e떪t3_uź+xR}rlZ-ҟ' <8:,#1|~k.?l?$IB~ܯ4/;  IlXȏgg}vڨ22222222222sIpp@nؐ:Puܹ=:~>Z}jEWI{U$S(a[j4`Nl/6@E`j g`dsK$j%Q1MW2I3ILO0iwtquS9:K7"Gv/vF6gsnkfC~iVCdQD0{@MKl#m,K]cP>bdPxǃT'"bPAqF.]B;oN+גe.tetutë4пN !H_KSkdP[%}] W?a4U$WsI8FU3W7SB`=e^ɆÎA'av#LAtoa.mcg1 dy}?+]5 T T ;Rsh>B2::C.;^rv=ux{n_ ONRY&'w:v'967ùf 2z]o_G,?75fOGRM HyQ2<j1&jTW!)v1`W _cֳe%3 D&UѪfrݶ1qo]eeFi4x wJgҟ%b>pi0 )\n1@˱oJ AM acGym ZМ` CÖS3zH e4UF"ٚ#ܤ%➌~$i|t$P&q8\(q_-MrdbRRCNQk\H4~}m$11դ!0=S}+ k]6;F^ZOi~ۡhhhl\Nb*)(A lf8 Bч$)h#8#3PI.ׄ "-n[{~gD4ⴈ~)7 c3}_JҰ0aI=M> H%١0[Pz>p)qR67T)_NjҸ8qQH~Ӣk{PxwEys#ק.≱FN +I7*(Oi>wejɘ,Abt*);|w2+=5WR#]ckX%} dFJWhx!likSmه%' Fta챨`_H&:$;s֬UTbHy0323d*<2a%fj%PC@> Nðn(ĮkXe^as"\c勸Q)*yu#Q (Cz/̵#-HRaa;G2d-  ƞ+eAf/C#0^q1t՞U:?zkQ7x$6͋w ( m62!66jK0Y^m7P+f+{5*:zro20˦Wb 'sOvq,qZr0*__h4^X&U6ƿ}f,@?Adӿ|s'=ϔ`-زQ (L):ǵuex})NɓB҉:m̜?`u6O#TR,68j%0MMt @yH sD!&IxHYa}-5/{tNB'p۔D ?e_=ޒTc^ɤ4W?: q"8ss;ơTqiBLv{7uDR!(ŇRe|hWYQmOU:[ޚxeb>Q5,|]-TE/MU~R߶A3:ŷeIm\0 lSrKp$6R[v1c9>'v''O{7_^}bѧv}:ev ϝɶ9k֓ߑ_CY?k>w3_0z#k?{/c~JنO͟m=2qtΆCAr6Ydd3ʼO"Ah@-8gѯ jzC)w!bH[BW}Bm\3Z~qijh64{8F^!!r`SoF>reGeJ]/?ʎbSZ@2 $ eF}IJj {HԦ CSk&_R2K&+|z - MC+d1n8@P\yσ}=3˟~/}?CHyaW۞^.a{-&IZNǠvl<_6DO(G0| dI8٧4CA.Qʆu )Si2hТsxeM? ԨvE^^g  n|4(ijn4 |+$c@`HDGRA&Sg?J%2IfQtCrt7AUY!w9i|ct>'vHh=s;0h"|;6,yf ~`uXн"jn3T&U$r/c")gFGR}゙[e5YrAoÛ8SUrfϊZ]oy^ZAA$$rȶEmw_U\mqef"mUu8=\٭9ܥ& J;f--+r-ȒIfa[s2yhlY&e.n_vC|I^֤KAmaz1v9Y5mK#ռ%[\\g|ScʉpbiiYW8lAe/Q5Zz=W+i| wF?G&im(lMMouV̫q)voe# q k:[5LyUgݟΞaUNӼ㝣^ wi|#NH 5T_,Nj%PMͲ#\HeZ^c>s:6J;#-H>) q7yxyˆ72v0pZ`Tt3.D ;IVZ ٌװw̛l!BOa[ce[πDSBЛ9u%6EͦG͍ ? i#ΨI~P lLzB9VmTMj5[߶[,_f+' <`X=[Lp\o__*k߲//hD*TCڝJGUj;ꨊ%-Q 2׎`4GwX8)fR@d 2, 9|]nӡȜyӲR{m%~kV6/u);R7bpE-+Ynj%NaW r&C;ɗk_mUo2Ո3Ai23ZTaSGtjeD &/5xKYeXA+xUtrG o=`V$xvfQZJr|Ϫ}(&߲bHW"[Iq6-{"th򖧈괍׈w R"ZDž caAD0?ϛipQ|@>˿)6 [&HV@@\1cṶ[l> v4:\NE:d-8Pv=#Y;eTޙ#lsyPt^֏;]ekKƙWPg^dU Dj9 q0WH(0OF2YOW{p뒝 |xَqe.p>f>h{j9j a׿{cZCNtDqCk:F%hY{Ӻ&_>|#H؆'I.mADT()RCL߹W^,l5v[zL7!(z]YtIdM˫ɼ1$d(9ZdaW_)3GoMg& ,gaؙ7y~iQV'O&-f?TVKʙ[&t=" TsO4{f Pjqhѵ[)Ä9HӭLsUضn 0H~ҚJHUYW = 2b4J%!'lu̞0I_EIVPPBIբ|H޼.5DU'|z+'PxU! M岦Q3&?+3g.S4Zi WYV.ρJH ˥޵XO,*%8 Oj^]|z!'.e M"*'#4k1k⋧ps('YD h.ʶmUɂ2_jא7&kmE5=klT5`F4 GW&1Qt"Ҟ.B 5|?[Vx+]T2wH)fU0W2Gĵz_[YqMW})vQkQ:?pw2D*G?*{vrcF{s;ZaWO`lwzλfЛQ\F.Q5}z\ha¿XǞshkTCQu ^l<<0Hq6х9+S]"u*8HUa Y3nD* ^j09 zOz5^ JsuE$BTܞ1E}jk?Uޯj5pP#$m]R ;Q ~HGZm-wyQ#ɔz'8TP;m(Ԇ[./zq7+jn-W!X|*2z&U\kS`;/_V!zQd#Gtp>:8WݟOYG4˪{;%#ϡwA5'˝8,` @ETn9q+-Y9ݰpzZ.0Ц!_෫|mq ծ=M*2UC 6s0jfUo\RcE>%P:8f^ ⋿pMlD\FB8JOHh7=2OXא!>-5)?FԢlVٙVoc rfsK0.FFG:f1gobM+̵G \\R^ z魷10m|%Q vT<{.&rRܸ3 >vLz\116X^Df0emݮ!E]rg4ƝVÂ4V#c LpjY2PjcruVےz_lCĒ5=-5Ce6Ḃv!k=^MIz٥b(GzLve>p]/[VJ[NHdÆG]Da+^]s luiU ^W:>+Y{^ƪ۔Ԋzi*U.B1O:ȻnF)(L0NY:NYTr`+u[ZK{q-mZpe E 5߮Ah%SH"Ӊ@'ևUߠ i3P5.]hRG|0Na'nRU';T%F`ܽV'^0p2Lz\GݞvbPe']7in;"Ɣ~a_nuc=/ 45%.wGĩS 2;+rTS +Zp)zc(||L'ɹQșf'Z+YVTb6AEs~V m#Z({nztNI4} $EqwUyh3ԉ/3*zT&#Q ꋏVW_& {o~+~.8% k9'Wk^Jj-5G=*crŀ $^cv{imOj$ΒUwws'ghxgQ˰y}ET)z+=37 G m5kK͋!qb~9YUcJץ2|H+mB"袯N#'C2^FF*LC'$ RJc//)3e`5 ; GxoX!R~| P{2v걉ލb6XOU"5KD9?jna[%t1}#?zBK~djT5,}I:wyu==b4 #}*&][P+ydSV8&ATP}q C2<\wNKWTO9EmQCesĝp"&a;=M WrkݿDD45Uk @@#:W2/7G"ѼX8U9 {s{XqI7&[b/ vG&bxoTv%MUlk:ܩJxЖ=f%~SZK̸\_q{=\{Ж<ާOKa4o*&тv;2RwbL:=<|ᔗ0Bku!~k>{7%)/;8X.^%4&B:P(cS Am_xϚXUT~L4 `mLؙJw*zi.nuĚ[S[o gM'Y1vI ?B1a>K~º~444a-~ h=o$~,(DLjkA?YѓO B;ӳoS=?wY*+wroʲUN]^\ƥtn( eOYW)٭S,W?Nd!h' =XU$Y)D3aԵ4wSEcJ@mB#ro:;Kc5 BrԌF{)x=),.19(J ˦ UaCZ@"`H+|-0,=qZUdF~44yb+4DSԶ&{{Qt=>GU%:`Ɋާɬ,z'ic&)͇ԭ{Zx۠Uɛ}O)J*kTu0Vq "X9.:4LX¬г&%8'm}BrWzA+˗t3)kdhSy}v\~_հluTfS{%$chZڴ#MiZPH7vɩ~{ў%Tmuse e6cH-v̪?z)=S R..%+4YPsx`~~;Clغ.oֆ@Ƃ027HD‹1xh0#^N З#wOT,K:RMzJ<Z;$kz[W&V |m/Y"9D*X[R.FXŻTPS؄vRYqQ;NJo:4sG|܁ܱ?[ߺGgź5r2`.{w䂚W;ar3-:xJ'ϧgr}1%M))DW55o}c%~1Y6Z^l#5nx-xbq6g_ ĠkfcXֻ_uF ś{#?PtǻY1[ գڋMWݚ?qzp#M=, c% :~Aߊ2PhᖻolRB_%Q~'6Ċ̉ UF驯qvQZB gWf9agW`/@ vʘSa*OaʻP \Y2jcq{c^bdi"˜v21}} sތd+7R;l4&?rjB;:$ޖ[ؾʼ d˟(0'_$A: BPvsw &aCtB.tC[xoV8]#ܐ?R)k^11aYlWBh2HM2OWl2jSqZZ'/IxFѶ6  ,bJ:xrjsQ\sZ~Tm]WZ'Zj,5՛J+(q_m2 1)ўwG5Բ :ô62]f\k(nt5b*o̝=ЀBh \5F.I5Ot֭ЪihU<2K5QgJ g' &#F[Q4a3epI';J y4Y%1ҵxؽxP3Z[#H,.̊<~-wdDӮ'86-815T,;0ňhC$6_mDyV&wDhp铄8å~]ᕽ0yER|oAވ1TAP(fVeG]ڨד<2OYE3+wL4(+s+XI97LFCokhpT]S8+Bp',:dۀЃTd7[ Ry!WOBe曳ж9Mo?>B4eGi}zh 01AYe ÆfV; m ?_NZX3? sAkWzH>#JI$Y;ߊʰ `K'mٮ84֭j o nRwEǜuџ1[>#iltȄ)ka"^ZsTX) NFm[SOcєuWˀIt1ګ&&&)6 D%v (Rq(Nz2)2: ʺqIOKJ0J  ~Jz.03#?E xέ?&.cצu9Ae,g  tq6hvDhW5$NKvgbuWao~#|{(bu*I8i[ogC!k\e\m;x^y4׫q= V&H6jlC Q?/ %7JI}VFP5j}w[Bv@m~E{%6(ՁEпG'~%>|k+p y&_gAK2/]՗̯g޹G+yO͕.'さ^Uv5T&5iEbK{1yլs L=eA GD=1&qNF?kœ7I׸/Wܺ_I(j Q]\'H Qlbu+LC(.: KunNuO'7}z2Rl޳0NW'x",<:u zCadIEIaZHP 4~%Ȼ5Y$]T#$hVDWG`򀺆gxah-O#:tkjn)'nMfBph8Š;Dw>vvr2֓5+&ԍ!1 3>gE;@-ݡ~ch33BS͆z3k{dG95tSV_;L^*Q^ScgSjf}*)0߈?vм-{XL66Ev9=k/䤑o|8Ý[*>Q-\X/ȘO0<-ϾWVWJ#|nGcxȴ^*bpnon?R-1$_{='T?tkw/RF )gARD^77hG?x^\lG 꼣86'21AnE|;4I`+H>XW.E9>kiPݟ̯rD`4RFLg<=4e#+wөlTiv5:9KZA~4ؾQ6knp 2F Q]9th5 8G.hn&QI3ʋ2}DU Cxm+o/3F9Yݚll2XARE?lj=s$q/}:>UّltON;·*y`{:>GvߘM餂K2'0g#]B12qgSSgJWymEhis%E.1,Et_-!Pk FYJ`k=ϊ|R;!@6v8Fzb*T/,FZm4~q^ժE'~ 28r?蔐dM?Vx6BW*RSD.<фav.rQɧF,^iLQ5-ϾCU}?O{lAʳ78*"4 hUFpm_Yy³!^IY HCfW?tb|:·?Pް-1boGpϼVR{Mmv$v{mB8E)'/p0y^{}^Q]#C*lcEc1e >.LKZ"7ծt. ob'85p}$1'dߥnUx9llv{~{]DmˆYYo½iظR*sZcM3j %J 1!e_]C`TBNvz4qǎ(/nGǞu\i+w >FI ww๸QɒE]Muͯڹ װHu8'[iL;3\qٷk1Y=<: ن{-!;¼ͰĔd9 " 'UFM\c$LKUYS11|v|n }S(>lQ|V/rRv[y&i%3I~/³ž\(]%o83[xThe?Hjf,'bm.M !8L\oC_,E&dn/c'vR8>%&gfaq{VrUwyof?Lu;ZS ZSp(nƢ`u@| iVXLE4.QQ\5v,ӽ^j:0dn6(zšywDzRP/ H&:{ËΎ4w"t 3qO131_W^jg_Wxt+D#o9zG"KZx=)ia-"vIwUNX1o,BOiPNYg ސ\Zz#@؊k%kNqN#kH-n!PuPͺ1,kYmp;xab+rD Gof1Ca%ӒNf!vc…x6θ?yd+ _D+-C_XHR9y0NJw7U_*Vgo'Αt@hsNw''irXSVyY06[KҟB0XV8g)y3)Eb"Ww&^9]Ysj+/\?&2%>oa?4$ZkB|s5, +¶9Iac_)G+bL} ^m1HZH]0Sn.Y'pܞ+0`,-6&bU#W*IU*QJꖂx3AiI LϝaV8Z4mA+JW.V1t݈F{xawmX5g[ڌpg{ y.6dG努d1@}Z )&w9Pj2@GRB:مE-ɜ O쑈, l_MJbI65w ǰ2oD*1+|#^at~^'=bǿvidɷb4ʡXr+x)GIeJ~V e0m?h׋a $*\$kۯL(~^FEIT$j1!6쯖es+Z=5?&ϤRg+j`wnIb;:Z1,ԝĹ5_'UD=Wv˜ d(*)M hW$dBy iWUx?681W\=cq=[8W&j=_&[$H'v 3$"Σ`QvY&tJ+Rt"-HoE6zi;Z'5a Mp G^7 M5_O~+/ a\,!=Kya3gvYכ kd_jDk\؟v}xdmdOoO΃ST/i^KOLܯƍHTe&81?l8J֋ A~Y+l1ɬ~?Wlad1U_ڧrv{ia4f*=#~#KSwLndw٤*M0WYyu_7~zW|PW!d_3xL8#}-lEj@R3 lb\^Em;W5uRIGrDP} uՃv@#l6=p2"RPۋWg5٬GA*2 gq~Z/w3*YwUb5P5b ` G@#߿'D]YkWy65Rh>y/W<ɇhjc,F_BzZo[ :bL5MxO͡`6o6""-'=EE[~"E-R?DBϔ\P[(C6;Eǟ|3IA =ˁjX5=16|I1*q,?_.ˑ$qcG3DQ1? 9p}i%X KDfS?> |uzjCa_vZ^IBgPr溼 .+}.9.8?)@HWcf&JϸVnY<'^^]+PCQ2clje)1nΨ@!̀~2'+kL[*2Tuqz+5@%8 ъ)niy&۱1G&P^H֖z`H=fKf2EP* 08urs[ q 1$U̐Q#+ȣhåj7nAF>knU &;(i6Ioc6WMuanڣҽMZ *Q'ʴbvsLlbt>k2[ҩ|x.AVp:+94b5\knz GS67ޤI`jSw׮l[ӝF§; ˖Y|[ 8*I#pH%!e0hUjzafcmZm~uȵ*}Pp t"u5<]0V ;=R-jl vȯOxUmL =Sr­$yxAn <ĞB) Zy{duv;cewֿcډic]ח{ǝ_?w;{OϪ'$̇},} N)ŗ!_u C6q !<&)䪼^3/!aOa|4(r}OCsR^;A<8:]WG7!yծ = r.^[ L&-c?+VF/ ǩm 12-Ҷ\Q D<ҮW Ϩu}48WWX!D|XZQ;YٯMyf)G1ڕl/7uf&ds-u=f0?%usPͯ~ΖL;Mj R4ҷхK[ikv6hO|z37d:2b =As._Q+S ^G3!z<xlSQV?7E 6j+o@v{鋾W]qҹYC+4|5o`VU W3*ñ6j3s̏f.ԽM_|oM$gF wZ}Ϫ-f l+S 6\mz-խfV.Eo rXAYnGb* N`GB;xQR@8a=źt_ASJ>b'PtUrF[\Z=0k@9Ӹ^s Gv-wl@ yxh; >Jc& hTd()'uXGd;)lvz%R/ =p+/e'˽n>+oV:`g. E' zn3a̛M "VVW1U\6Vyq`eȞ.bR[ꮁo3|V U1/qwTd); $f5^ v0Xo#oE5w;79smQzIg&˹3e?zo|5lŀq޺]M ƭiq|wKືͷ/E( H!t|q,߿8s58|ppNɴFEř{qcaPSDi,1fܘqz g!tQo,7I9r6cp6e @ϖ {mnY;(mmL3CUsl&iּlq#O]3Q,ٰ߲]=,:2:J,QFvx(i;"YJfq!jQTvM_/W+W w[yM !@j6w egZ_LYik4v#{M%h0k0]~l$ODޣL|/DJ Ju$YЖ.IQLWhH{7-OHTRg9RkgY qAF6P6̞B|橜DUH€ Umaz}o>ƊdtC/Qz\ Lq!ɌWBYV픉&{b&7\ys81N % ~Yg[ GMSwxTC5fc^ϒJs6y6~VQM|+?R\T7)l&C R57ڡl@4׷jʪ~ĭgc-h/ɷq!$$4zE9Y F`IXLրWp&n2dhrbi2w&בb~"dKu_Ǽpm# 7G| KB ֛Ȅ\_9lpSH(utg4ڵ#"P+1]֘Fy!J$isz p:9z zV`^p.bM)p+oHAzV+׮+j@#7sEiYs,C̛-s5t6Y.j)>[REU>c'Aq[ė5|3P_gbT%aNWP-zBa}pf-.2ٴ^~X' f^! rPRdRT2[ 5į hP%U:(̿c+%Ū!EIDIii%Y86OgI np8MS&#0px)EòO\&cdڂ1pKaaJ;3VӖL~s! /q*s2}P#A:|&Ekݖﶛ|Jj(ϸ0L ].'c *I皒QE<hsR!(ea!'h_L)^3|ؤ_h2rr;|e>s׍]Tc1LU# :f PieO`(ٔ. 떙"1z/0ʯƅZX.h=IZ@cm_zq7+ 7H~X>)"̙^|ȓ<Ƽ3e3xG9{>uE 'd8j1UBp?#eo|wQ%lpFux-PBXmHo:,r0B'Qԋ,E )~iiRm5~fĥ!i :ć墈y jM@>kM0fʑ=;[!B<R|)vӐGo{?Y0&ď}ָJ[d, +ihu\5WGZ Z%P< vßT{MT6`A?W !nWE S MX*!<>XS (8`S+l'lBQd6ed}P@vR%}]WVP(>wTBq϶ߛd r}~8H'sJ -T;H ?nk&][NNe .77V ƋOzZs̯M1FJGcaVO;wt*Uֈ^˺6T }LwdlSw q}o&)EJٖN2vֱy]և叔 6@LtlZ 8)i6Vjd+׾+kנ!k,4,J܋`RSo\r樋Sx%?2Pu>hhKjH\i,.zeCӇ9#쥍ȓ7te=8*eXE^u=vqg[=J5yF(`;2}Vw㵅VGMK7T& =4_0dOͯQvjK ip^`u!>T3C1d"4p N:.q65 bՙ K~Tf?EE_jh%ln_;O&//Q4PжS[aN,yivZ|a>fhQ2I {tAPһ'j'W~cmfNQĿ%)b>Q2?ct=26pa񊢊>N嘦QOj=2)Q/'_~Dq\H(Jt(4bG%w1j/"&Fc}&9vx + oG.,8DO4[LנMvIW8ʳbyL {1iD/7YJ4p4Aеid)ՎrMJ7< N}?%тҙVd̆a?)޼ǠTõE3L&3-˸4:km@Cm *g1?0QuɅv S؝ol8׃5K '|v<@lJj"ך< E8Sy[OV6Tm|f&@7ن-'Sɂ|MZPI8e/oHߔ($Q\6J'jf@wׯf#ZŰ"Qlc */pqn}|}+iBͶE[J m`qBtj51APpqY˜#xOZu;OFd"K+؄L˃H?jZh{Ȏ 2ԄʞG}@g5BoltɳG qHFD`<4}L3>L8kGHg94IK̜_UnPۆHHA=k D~m\,)ƸO𠐶NuMmLtn?b Dg@2Ei@o2%1 "9|gY6m|lnykr'+Kn`V{u6N6<:鿊1U·5؇y92S0@3P]-6$,axm3(X<2_s2^s_e=:4::󱫚+F׭o3/ aWOIi. ]]u<58O˗G˫`ƠS^*|cj1Elb-ԊYB-\0h[~qAFx7<:8~>㡢hƝ(JN&ibLNN [4JWl AR^7nJkZXT7bbN񁄋o gOԓ< ۮ1˰v*Ő DFL_h;LNSLJDQL*:a1%Va'"pcʻp%r@*Lf 'zi2F>3ȥVwuv+颏S=V\Ɂ.L,żPtbZv R/RQ^Lfy~}}~q,V\Ԯ՜ROr͋),[u?xpl%cNfPb.P|9Vt%w:ޛ =UzyS+`RK0OpWW:q+pd~VFIްo*v/ݿ"#~L"&Ot?jAT` ,ג!iP%J˲1'$ TFW( سK~f.lhTd]7n^,m9FH4OaP謁?ߩ҇?s~=Qi ` 7%(ԀUնS + 4JӸ @ dACrn΀*5SECaܜdĺbl tM{Qiߓݝ`^9IMM[/\P?&Kv`Ne?KA,>p_#f34ꗚbٺ؍>9KsI)-1j7۲\;]Yĉ)1p =g.GV A~?}8|GYuCyKjhoAN ϐb0;4|Y"cK'8Th9'KJ]V0J_ENp珝_N2':= ƿQJÉRGq.SI=\jՎ}$!1[hXCJ I ^%7|8[pWid˅_kP`q#4/ V83 f7!!cZH\\:ǻ?Ϫ Y%UGʢ{U{_wJ^Gx= _U GU>V)%=ONo)X_Oi0U5;Сai44iԞ3a3('N+E2Dl* /$$ B 78fӛ:58/=;!-*7H;t9D)8?[‚ZuA?SU<6fr3rx*ľK4H3MߊAQxM)_ ޖzE~+ xqRW  'Ig42|*6^ņ$$Tz~nhnB4@ܥxNOn':dvֈM *H kyH:mZPGMZ5J,Ky+"TOƐF}˭Dpԫ6vMؠPN1vԀ@h Kźũ>=k_RpKfŋ=U g65)z5Αylu+@*N) fE aV!"eO5<+&ɉcl4ܰ#3[nU}k:h@KBX0J2Av<eU_p1띏6nDGq⏈3+4 ̣㮠ԋU>pRd~.bNP*]Au? 0Evi1E-m 4.JBٮ9b3R4Ê sA J{:a,b+"?Ux>"l-C:FUÕ$ V2c󍻇ED" >3IBy*SS]c&p vcOTڵG({9pL+_*G5ot'cHJĮ@-G9]BVr[㴛Bδ-^;v} AW̲ BKk%M! Pu nP *y IJМĩ4RStqhǣH1@?^\)0~=@0UfO%UGB,MzSp>b;/.Z633^*ß1̣Y5ŝ|gީ+0%'OƢ%\` O*I-cUTVB"rm}W} mA .5=xs3in)~G'Nk.%^B&|W[s\45\٬ o#e_O &TƧ nPݪ-a^Iz:PRWM8L't~>*_0O 8)6؁}[.8\<)b ^n7YVuՏ7S|IsqNsSGFI6TDp#Ub;Ut[Sn=! !hw ΚFp^LJ%Fڰ@JMx>]pQrTL7zIm|-_R -*}GyYV K.$74Jd-ά84TIzo͉fT=^g7 Z+v^yބS 0zW`ۃ~kO$^͕ۣ `SWz%ٲ?m/r(΂xKPA8K|?'{7A"M6N/n uK59:*uAf\~@Fp| s ZcX}Lm+҉m=̖7`Q+* PjݗP' E2MISOCOcj-(Ck64)fUP3;e(3h٬}<ME^lnyyR|;7FYri3 >Oػg:2*+q%[@ŜcUX\`C֙Nۼ!_޽G?Bݹ;==k2DZfǣ.KeG۲QIݎ%>@Y*ӳgϙX,AI@nƴrEMF%Y=~Ȱcg 5 tgaᑏ[;mpRw&:as53꭭{e^=@Ras0ȂSƲM(:/Dbbo5oUa.ceʷA[- uund-G)N]c~YՃPO`/f3E$㷦o/tR[¹~V7ԥ k2k'!@9 V[ p5eMߑ M[! D3(t,2W?x|cPF %MαM\$Ƙx)sc As|_)kyǔP{BULy2c҉d%k9$ijOÙ{.!~qz^[.p,/_Mod>w0pu K_AYM#5#.jwo䱼q[Ȭ܄̖ZbaWWva`PwHXH5*7izhXr8jwWY[F/3C+qQM5i'y2֣le B"@i>,[*o?vӉy« -BC Jx  P)n/UxzgQ2/^a!èYOf4ax\>g|Mi_Vh5J;;۟ZZlG>8<9Dƕ~X;,Qηh71e+L~*`lQ/^'pO?h.#je1u"SR}EZ;K,Y7-cUI4H AUӢ)jz_x͇Lm(pd>T6Vs`l WE:yܗvSf6TuTy[ gZKyp-_Tx1 Y$//xÕ%'~+X9k6;+92:ho?X$gív]f=` wB /^_RTv. ^b0jW4If*+ĂwS܁ ZZ[鄉&+El[q7g$׭%/óDe@ɵkЭGZD1ޙ'!Cv^#W. 6 9$컩U/cl;\p=1SO?خ,Gc- E2[ pWGs/ WQ8'5tCjݎَN1cq/GȨ+[|З<O? z5ksS";dsNɏ>ny4ۢ=d^^[`zJo{ʜx91]STM ugX}&̄(G{W߶)J K_b62f^'Eh^d?R}3:plqXʝf ޅDZ͋I &K/^|o%| RhfZV9剪|;gt/{m#~5<ݎzbдB[qr7klxK~ځ2e\:]GDU{Q.wT5Xg/> 涩(o]2ӽVHĔBOx gU FY : [,6ï#|S\肎Bh7ƷoykxFWz=t)(f<ЌDVےެߊ,O(tʋal*ȻӻrLxlP]%05MW.B5)E\9Gx\hQous|bo ~8voV㏘4֛3+㷅/ҵK\/"cK=6=#tciH?>j̅4(ԂWOz˟?T?Uw᠖@I8bU^Jv3U9pVۨp@+p*IXϥN+9dʾ̆_tk,0-Wd<]~mŇ.ѾXj܏c=iU7# -_r Þ0H '3Ъ}+33}_& cVA'HѪ:Q򒥇(Jںfs]a8Zx#%E@~|i^iT4nanv{GLJڻ;ؿxJ-%:&ߞͨ;B$@) ^9|Q"d39Mn 9yqv oZRЉcƒVص &\qU><͔terRO"q0c? ^eYS(1^JpNYz ^ğ\{Ǔ\eA `{59ݝ|wgi < &1Mn@g|rq 2ܓ2P{=tE> Nz{ N|`^K)k`n(ދ=nAЖj.΄QO,Yqn8dᏭOt3zw> 9'ֳ#aԦ?2#䛇 LP%aׂt}*WL|0ڣzTG< 4%X vB?&vj8bsAH*0C̾qUJIOGbB.Ab a+XP;b2]6=ɹ!gRY%Q1Ѵ0P1A=2=_,"0ek(تk{1a;nלc[#MBkV}%_ JjXPw.b|#sV|zlfZ^W+704kDiȽTC.8o;U! nƧUZ-XA v%R` a=CRzj@/+]rMsFP}2P&АZJN[Wc <a9} c18f*PX!{5[G65KYe]H|XpTdz,ci7{{hN\:PuFU<gdw+byt߬YΖy~cR猴(gN5QO{i2M\7xpJ SȢ7l;, ;u`2M u}Շ8Wb{kgH믚8,*:9aQ{(h enM_.E4H!I}*H22te[Vprl5? Π3Yz5,29tSXXŇY2""܌ۂ3eMR|;JfV80γY:2H{QAP r(jVAfZYZ@ -iDm+UN2b[Gl': k9| *g8͆gЖ+'cxee|>][ (0-_c1+h^*iXGB`#.a98 !QѡG=27({>ca}&Nm9{ ZI06i$\)X$ZU :k_rgn"w^3xpȁH!!UU z[覈R ӊiQYBRT][AA^I~QӧmSyHci,cv0y7|ò(H=8kn^3Cv{8|` \qiqz[/KL;fԄwd1HrH:!<5A'M*GVc s4dS[79tNXn=TVb ՋwZar>GS( `96SrY>1A`>aNһf$9iA!M׍\5mF<K*aA-b̛^\wzzczf q*_q>O 8\m{Mv`.5r3D(!䲙ſi8-&Dnme܋pl[Zz^呩Mc2`q\Lq>r s.B39 l,ɴ>*ɸ^N7/VǏnz KC^O>ڎS0"'B'81uOU:3OI2&&/[ E1:"ȕD_+V@rb#F1}e`C6&2[ B}'~ R|z{xu-5}lrkqڗeZYP+>FajH40)ib4xzp d17]Q+}FTs. !ye}7h ( \ w3m[w&r]FeXR@- jgP؝(@0*"U{E*Y0M2ZǷRʻ\uZ:kJ.z%2Lp?(86ZBy:"Q)DLIWӐ3$n+pla,hz(,}; +`t>h\[uA1Ѫ{ȳh|~֬dIشswxov~<>#N:>?y洓)#oXf1GK/Bs1e\:cWO#;xKή(ۻmq,j~d<+<$'ο3()rMO37\xmR֢xUͲLxmαJaQ:Z 7 RqsJtU`HF}` **wk+oZakYeBo[l>#5$y5B[QrHJWL춊G%5ɵz]O-mVn*4doBm{`(u|T͜FuB|2A-<@ :߼[e!I {8% s+gSټ}X0ϝnk_;GBCud2wE[K!gfXˍUXv31 Es$uqPx,D41]fu`,)8NUx4Wbp<̿XWb~nj͋Nꁋם`^mό|̡w.=F!wOm {h Uey߷d]tr  :$⏭kO1?@o00F`MBoU;bC,!sš6I@Yvwعe+.)ƨR2߾mR[B4vr(qŹPRF=F^^3u0Rp0>]އ'\G9R`P7Soe/q LZݺO.wrjTcx)$~v>4rr`DhD,ZI/w˛&8NB 'xaWDTS1X9^_?0c bȪv.'Yd%ufjrˮ -Xl%Š~OE)%aǔ|/ssD myqQZ t{xlk: c>}R T(a<[X[ 1"w :۞@GdYfzEBP̒|\X?Ix$F8? T #%6mlt=Z*GRdC7$eI0 Ɲdlp9{ geƛ C+{[ܠ`[\EmU`"p1-ٓ Nl։Ut_?mۆrLyhī37<Ǵy%]ٓlTͺ$4wb Vi$,Z' {@.z( Vs SK,OEUVOVUe{!P\  xZ@D;hݟ #>M?Y^wn%NԹEw:+R󴼌5j!tRed`ִ/T'FI+|EAL3>Ґ-iEDf%yau MR{|Lʅ Amԏt3k呵x g4 mY?m.j>CN$!>|)άoNC =&aHt_4\:IF&o~Eը W,@5C\ QHyZCz%Z^qȪ<1i1O`[q?Ŝ即:j$rb }_l 9V$85ѻ"SjwӜz NUFaD-t[۴@솢5g4Lj|4 QS<]r6f(v(SIAeJDӘ̍,(33>m,g0đhSM#"2dP`|bkCy1A~5(QrCpAiу,I>oX'o[<1~$,#,u پdЉJdo10 !Q7q.g^ 1T٥bn͜0hqvXv9EΠIsB񿙛U #ϭ<ΐgKF;GqHE0X+vZp~l+{FN!dz?,;atSb0 `^g5cv:.dژ_V?;;HuN@e$^y N5:gEdou:F`Ѡ]3s)doupy]W!dLNņš^_ޅhOaa^#:AřZU,ٴWs#5QVX=DK(w$"LSHS%br]lƹL`ﶦ|`,oJ؞2s [䚋)X|VaM:w?ivyn)&mruA-.k*l>]tW{c }g5=d1JWeN:ەxB]X)Xyȝj4 |499x 8>^Mu@i VKe4se⹲6.)=KO(},35+aRMr#HT)MtsU5Uv(0zumؽGVMz10H!K @JA)T"N'F\/huDV$C W'5u|Ba=MkBbY%32]X7wz _k=ҨAM1 SbbRBwy;O%5ĊX]G75 ^ 76_D--cZ7>PyN f,?`/wM*ĬLqYycj;Yg )9A\R8?Y/s"_zv2zA׺V a8'x3 u2uIJ~k J׶6oҬV|&y3ﮨR5]nC@e]e79ŤJVX@Otl$YZc_jTi77Z0QtQhLU"B8D ㅃ[>497mm ImnlDw:\k"*3xp>3|f;^\܏OPCoF#WIYMxwKy}R.v Lt'5uyԅJLTFf?D}V!tV[!u"čZu4|lŮD Wsa'aU-QXj!IK}CՅIIyX]/CvhA\?rZ\`L-GGٗ˦\I$:S>SgNEL'%錠~FA0{DؓNL3V7ME; LȪ@Sliѻs4@pU٨,oO(Rx eX ~oYk5eɹDŀ@}T%@_W_PC(RI+Z1"'O.箆E? 3g#/YH:]\E PU)SZ>W>n?uwtv:fne\uZ&bxwbر)C lɰau>o A)[cSxCwx6Hs?Q{;邳߄h.>e:yͭa:U:"MeɘMz%Y"^ 5-?Vjr!Dv? G)WGvl*Jѫ?nJ)RH?)bԯŮarc͎c Cm2^0P@dΥ:RuQ'D=m:rwAJWe~ƟWW f0ܐ Kc\p^row ]5(<7E1 aoF299w5UX($A aG3;&=PҿXЖ@%c*ׂ//~Q -/7KO),%dIfH~jb}HU`&:9rfAJ(0sb$PCFہs1Dp+,iC4p rSY?Lbfap/ƶvFt/d':5~BA;BG+P_xj}m{jډN|4M+ LUc_ Pl`qitW<c|2Dao 1ޝXZWr)> HU7Oa~ƾcqyTK~KOrP ćc%WU9a@$k5.i%bbGa(>/CgXk^#Y}/z+yS,x\[bN)s^wtBvvy6N϶ޞUyp u#>G૵q4$l-`2$<@96YR`)wSra8C7jMy -y@=!fPRv9L+2!JK]x҂mCbz\fgMzFS܍PRVH1ɿ𽯢1w`GOz졢h()H*]J0qv,* VBc{rc 76XT2XӺNL2xޟ.mjԧ~ nQ1=f `ku&ަV)9TRQpMj&'e+N::_q2QF" ?H cbwp?9<TGUهW|RhTgNd w.ďAD!gG4^*x>t,IP&KҩPTa Y>-r}1DMUk % EM)OVQ&=|cw@5[aIДB~SeBf ǓEeɶ>JXM9n].xs9Ll8va~ ]Vv`C} n_O[R<1+7u/JB\?Jj*KY,~άЊʀXv#ًڽ(0=k~7Ԯ2B\]%o}\ܯ#^^'&ԟB/sJ.!Tͯ}Wip \WؼU>-jϲ *3#)!6x^w=ޮAM b zt_bw;[Si$(4єn+$)xd]YbXsf*^s5Qa4pM>u?)}yxìS`'+8\#Q̓s1{< jYhYK? Ÿ1ުj%D++PNXSDcOfqɩGN/V?psو^<0XWܘ'zy6K$$2MObȱmd&8EtYMM=9L5wqn;k!O  T0 >Ca);†_1 ʲ_Ńq Z f~ އdv&CI X'26Ī<8jx_/ Xc\vɿvo*Ke.!&ўT_ = br%'@&p[Obp)! Rdg!IvN3~u+_B]j_r/{ LXEz}DeRO5{`(2򯱚xo@N7Pܮ.Zk =}V#?UK@:cRުtdP<лN #K~<ކSFѐr7ԭl,dc?.Dda&&X*F'ޣ%cǽWPنIX'L@jKL;dl/N~VYUs@ F$3{qM==u~7ƃ$:{sqoI2TLFL'%♆y:StyX& i3 (;F^mR uI^+Bc .2 8@P8I2RXQ9"Vo͊ d{VL>aqt:\@F*bPD/UQxXP8c51ݺs`]t^ / V2%,<Y>Rqf"s^LxIa-F/ ; 5 ,/ꆝutÒҮj.^p"[KU:БG""Qd-h>^<Ү]^O?`4obIh-݁#[]e2d,Y.Hiw7us ӫ8Y돪 YЦpXs IG ( #XLᙇל'zg8Xx|±DVCVBN-kޯM)aU #$Vyj *O~B0VC[Bcn>:IϚUVD8nR59Ơbx ͏sG_qV-W//-7-V@~ͯtvXG 0.lB")=Q N>K>0XzbWHEuFF#_yΡw[쬭U)zkPl&RjWSCъ8"zX[\{+vyU7FV.(-%8w(*~XjdIcA"~#eAg"zbeGQV}ePVRll1?&|ǺMnF뺨IkC^gsVX_ȟ X_OR'+DmvDT'gH&;jobFyRLbaLغV 5Bo%Z=Ԥv 7ΡznNه+eiezல2EuΥQΰ70%3u' qX() toD~hGghM>i\22b\Ӽw¼X4yosWrP XNf&WWRpF}v=DK u SG~H$XvX`HX2 o>:_hkkw|}T\oYG&2Ի]4%}LQR@#* RW!t+|vl a 07>̬.ٍ409YXԣY]?}Iɖ n!a%8HQRak[[wB댑qTgSq4aa:ttSѰPك(}7WI:4t"RF{Ӥ?ߔ#pQf/l(:Pi& {^๬%D`1ݳb* YTQ%% xHGb^-"y:ɆHdʇja8HDl4舑L~ DfN| jjVNM=+Ρ%urW1ⲡ[_`?Nuר a;jcJv2x k.M,`&^EvonʛiqtvqL yB~ +8g-al)(ۜkV7hxE [g8K bD,j*!0>ɼEdW7~%׸m_qGkaZd s^Um߾Qh`A-wt.c 9ag*󖣁.=]8Yk>c;}Bty!l_bK2l1X[EA~AF Z>ƺut)=t\yk&̩"T T{+pOnqC#mk 5Df'ˎ3ވ6!Bb+ݝF7Ҷ`=ˬ kK7+u %,OYar kiH1 Swۥq~5m%Q^'5NF .ŋ =pAr_<,{^f& f:To>]QM\f|`ʪ`AnP v~b4k^o0ɩi$~ 7:lw?Hz~>!-^_қ$V4yR#6?ܲb6F#ߪu#-ιpklms4mo:j,RVA ̌VSfnsc_6*ا烝؉7?3֧q9N?>A~Gxșqn6zvж{v^kUyHAUSmm[RDq=HQVڨN} km S OUHk+dnOM}p$ث(Dx'.I8 8-ƶ˝F7{o=0ARF7YOc+oFOh9sNfvOͭrlBPnłлWyM429zhy_X(o p?>78rOo}ϭ' Εt>pph?gHӉrDNuFfzVJE(%=N.ߠʎEU\w^sd0x? .׈,MwYP %#ECm;fQÃe N Ii1 h񟧰 ^<|z)DDNq)F,u"!L_bУбj!NQ١Y2& o9 5}ukHwp1?<ez{RHge0,ܣ?6>6i83saTx& H*uZ+Zh[#6B9z'݌߈rPFZ 9['p*oR,X)i n1 7 ҈XHjKh{淔K]n?[m91X#ƨ_&@1umhX6zlOT:s"b^FTRKL11}ő6յEpCY4sq2/kjB+N .~k3lm~;%$۷/=XIr#PtΫ2= e'7b?r9`$$B^Ͼ5hblNyV$8r%zH\^,+Oq h'oDn&J/[zga?^mKXZ; q8h$xG:;mjIzzzaT,оP\>+7@3:K2<.,|񼸬13d= pv"PbmpEQJcu&?ɱmͥWjuo>$w nevWOU);f%P[jߴ-!ւ뗹y[W o^ȗnP@^1Wtq!Nz TQ,ڵg eaC)\M붺a[^8ۍ;-ݙgne.Ra0Z6[ Y*lXkhb ??8'i'К~pnYM9ly}gqb&}uqQ _p 'lJ+ِ;O1pˏg t*V5WϬĪ3Ma@w V;A)`GqzW#Ɔonm܉2 PeX#ua-АJ*3`ge<d}xOG6^Z ɁOWր[_K{A)FGQ*؜͹0+Lܬ_ǹ<΃س ?Im.k @;VrTO,2$dK2m}RW\ݱ `W G >CJy/.&ұD3q7%=Rz nF )SJQY*䛊h=,g6)Nʖ-; h[IVçk&l(deh{yl(9rmUia)ml ;V肼d_!ꝏҝD n~si\ :q kgz;3sEYϯFC7Fc!opaG|ðP22$9 *pA1U u^$Br%6%b8TǦ)Pq`U8=ؠ&NQW1yXt g ִ-N]KN"gbopD*9$,!*r)aVx:,P@rľ٬&@Ce3"*62: :BĒ kFQh]֙iԱE,FeY\7m ZBiIa6 Sk4Y_cQ (,GIWFX#qˊЋk-F=|MT{OICGPsL "fߙ&3Xԋ)V]KEI *og?s9 P1KlO5Q9zu@nV= A>Ƨd6Lƪ"hHЈ'T:[MTlwb\,+,xWjAH(1/jr$PŌb6"lKBPQ/I$PoX2h|g2tF/%C&"TXJp4_۰}]llHPpȶ|;Rvp4=xՙ8f_&R3)hTWTicbqK,r=UScv0&uDU''X/F!W%֮p,jDcIJ;,<|og-# *7,>^V~5|7{{~xó9[*XewrMUT;wd0eEU:(q*֩.tl:{VVϟOXt,nS FQ)ǀ+=#[+U%:NGI+v˿}SCDϟb^^ w,@/ΊZs/ C]~`0/U7BmxY*~{dX">@Dw",td9~v8MhtIk^εelcUm<_U>@Q )zmci+r7V.02귔1#F-f: 9hN?;b>%q\; FcP}F>d_ѦPT5%+p4<ט67;/WL 3 N#$t/f, U탏%q"db$*4"Z+{@'ù:) r|tDFF3,7lB!g  i2 ŤbYcoFڦnȟ6"G޿K򶪋%Y \ɔĸeW_N+Y2!sy<TT1c>Dz׿L(~2@d4{_xT )jn76G;t\Ly}>+ ǵ0"R[lx6BI kTwp<̾ZpzuZ/1] 2w.L}o1i"`ܙˋ6a'Lo?F7,M&]^5s եh}!^T*Tt?eecY(uhGu4lHD`$ҧL;L-$ʝ :3; (e7W |y17E?E8k&vϸS}\?p bc)Qeٛ}_#mg=VGݫK%c1%s 1̾|6_dc6']NyQա?507^szz` :Vcϐ%)]念ypYk/k ˏ&) G n+G@*ME{N*@B D7a7!~Vo7z:.'*#5K}y2HC+(##vYf=u7*dx3q5l aOoK[dKN[͈nw9vqw lț:ֆ1Ք]0"R @L<e̘€{9kk5mWvyN/ݖLW^tVA;anys=Sֱv5eO^|5%Lgc1v3ՒN b*4UxGrtn,V?8ìu98/GG Q -ls*ʲY;]~6,( K5{K6%6TkՔl@J6ǚc"˒WhA(mxg| $sܯ0dnUtYY3A!ǠkL|k\ZY1 b_+1O3]+XL'6j\#9K%1*JTȼ=zjH:6̗%沷hI*1&i8e2%XPRWKz`f]=*uj\c]:ӤUx|^W8D?pް6EGi`dځ16ު:5HGחr5Ba{4mg677\`&]6xd6fc Kׂi3@Gj5Vr}]Zݫb&Ѧ~Cn^tTN~;⯿&,{6 3:9.Rfr K{` ݱZ41/ =ȃ;+jIFnφLΟH~¿ |'Ga #ܾta/hl^d3 (NŸdV `yޣ5mr~4)y7弢)Ƀc' 1_d\O~ JpGsc3w`Gp,(b\!褟 }QV&* n4ealj 1{9c G{Ɇ5mO;qI?]ۚNU뛬GYrx,{WL7}-~C1cH? >qw&5$|vflg5 &:LUǃuc@6uO?A0(R,:BfPXiJb0V#J^3 dki\8cERTb>1ٹO0:~~b|Ņr6y!LMXX rvSAȮr1b$K{ ;T!0_ư$'_lr:Br%-8̈́i 韍3a`\`| 9 {;8So'd!F]Ab,.gs?tګL>Yrkz뵃x#+}5'9~Ʒ[qSy6J6̶s ʮLyw8k@ oq>S/a&;`keX`#D_9S( }\QBQU&i% 3( RBѹ Yd_w0{͂:6!U165>I:SL$;b X@;cHӹ[܆0CA[ ǠᏟ6҂+yr)/ ^P9қFCe]s?xB葁_c tL<}LM4RlL #ݰ:+YR:"0/5(ؠrk, G{DNk?/$ByCev9&7~jb6DB$Yd5:([8`F>Bi <ϳ#]YQ[0\ʼ%  ѱ攋rweɕp(."R]MbW%ganZ>Fړuh"r?%YV],ФvJ fr3o$tkk;<@$P% jTo |.5{8.H#2qK2X1޿i%Uju(Q7۾ivu`qv2Y{m/t\B8)òoBC7VEb-S|^ QaWkNaĘײ=R*l:§Ca,aMI-p+Ep+fdd(*E'g]~(~LJ\Y,uugY=oȉFZQ}#_/*82<1r.M RfXsc-YUc;!ۇE ,.Mu9Lǃ/6қJ(Wŭ Ŝ]}M?PGt9'Ɔt]JE,bQJoyۛcnò.ҳp4_>섻pǔ1xo3uCX7/`/&BA`nfVd;wت8̙:Y2I^8m'/jr;T+(BδBh~@ k;ڼ/},YD6IIN$b.=r ߕA ф%Htªbg>n`l5Ϧ)fէSZBVtSUX.BYi8䲫<mBrxi7Mp ( ?MEY'ң0.~^Gѥfphsܫ,n1.t0miAdl<K+B -]Sʚl ,:V!=y9y:06>P?EdMi&ɝL` bƛ`XUTTBST>~>E?{ zM3喣[]*P&B$gI_l3 8'^zKҤTw4)fM\YY V1p?B؇},zҭmoѪ~<{{ n;lrw=]7Eſ% `EQh9^e T "AK~\t|v1 lCI:f>q^t?=%tH<{xH'Up6D%W>'ir=S]R?(y ?w)TO-<@U[|N ^q{{F[OU\U͡qǽplDf2cRO+>E¸0^XMV[z+nu{:6ZhUh耩v5j^u6ۦo7eDJ,K4K!b3 8YQ(ćKLp`(vC :] x5qY5oPF}mE2=c@Q%^rJ-vErxC3: 1P3]sKԊݣ71ulZVRUJBUgZR34`2R7-B{;tq[ 8h>ZKb Z=L:3Wb#_3|trw2-u`0)86ikoumZֵ!w"fF(ԹԸQMGP8`䣛` QA}WqfTk5THm`3m~~qZ!v6Jy䞹D|}"\0lc0z9E@܅;㊫[ SPY봺uw%YP8{Ɯ؛*ת -h/DAfm-(·R8uTo?SShX N$ݛjs8f6\ok5q#Q^nIx6/:箾A+vgMP\ã]u[U{pޭotHw{wp`W>|Nm=^O1%$Alʺ6f0>l(3b3ha07/G`,iNwuI?;u|$mT:UVRvc0_NMJߛXnhʎݪg-ĺ[VvmEMҡ_$g۰9c;~n&a}c>R_|Fœ@NԫYOWbԠ*H D]eSrx\$9ij JgU ;y1̕8}x iv( qvp~|,>,g6H0p(^_$Y_mQ,fדa&/Ï4J~6/pe:@BqHX-VZM(Qز:h6!%AKi;wx/aA!{JQOۑ\ZP9@hw)b:TWQ]Lb32o5TX5Dhq3ᾛ=*J8Nثڐq#8x m4qh_c,8NϣMߺuoT&4<| tN67fCuo-[=>wSqRe%x::2X[ b];}S‘*C+6U2vqdt0+#^ɱ"XEZXE E\?.xmKd"j g.Z![[dwV\1*JהӤuj4}&IME:9LR£9b1WK̓V\ka7=)Cg‡'R#݂ڳ =$T|w:V0k`T^>2G%&tA'-tokbMIs:[}S"X*DxX.#sVE$[AZf4l3;mq,1گ_d׿|Vң:-bvT1UH-U%DXue8AW_gpsj^1kf1) u_M& *P Yajaa4PV?Afh] +:Rn# ^HC (L@I^*N}lu:NP_YRfJͭ@#tkZx]+ +DW,a(AG/_ʞb² dAtv=RfPǫ|&(s:NP9Y EV/U>Jzq7j?*4+f*b<40͗T֠A1"&&MNE?:Q׀ u'Ȳ5>R3V2 i>wWEuԨ>>!q&Ci5k;VJi=~Zсe.ӈMbᕯE>*8ՌX59 +N)s@nұ])`F|*dC:!ˤ#Y^_Cݽ= dI43[-บ Ѽ+wlchKUg(~q`1|2Svt`춸|PPΝr\2H9՛s-! Bqk>4_ˉ%zh]xg-v>0$QbA?<#Cӊg&`Dva.sPs"[+=MUe <`W/'n{N%T7USeBOvhϟݟW]#9o?qQr':ӳlY/F -,̒9Xi%Zi>5bü64M ""f9b ِrp[.6ņeKd5|%}u AJzhq ,_m/MV(Mކ T0\yC]oR&,e|{O>e⪧̃Ϋ9>KpҐKW(bZIaM8UD J6MSK;kEa [iybwAK\Lp /rt`1z Eus;`S2e=],C9{Ń,V؄ Zc7L*!jk~Q,5Ů?{ K=Cf +̑K"(Ti=̽xs+IsnDMb4$xy5vjQ/m@BzE z.j:{IL*<壾)W*G7vZ`[D(wW F;TALeۥ]1K7S߱fok {g}ةo>-bvyejFIi>#}?`/쏒-RAcbƶBK_U+Y qD+Txrvi<3-5 t6<[[=:њM[/{WC:R>,r κvф77+P(<=uFOZc+.q5VZ0OYM׷/ S$P`g0@ ݊E-F)jo21hH-'|zbA@pDS]AY~"o!me`O^s}ygXҢr'!-Uy,BuHRXҕ=}K?ИSq(^G!_c;, M]YJU`RW9a˜La `'t>,(r , Tty1|EШ9IXN+*1NĠ%x 2T17E[s{&a< 6 ?-YjGkȲ~[qnhiRk. KU${WsΑV"5DSi+5M:e~ Ck!dq2VH>֣kvݵ\|S8r\& |C`@Q9?&ŢE:|}e7~5ugKևЂ˖v,ˆo|Ԙ|0k첞WSl, TP|'(gAbw2y|Zvݯ#X|ͼlGWsjoώGow:I}'A=wt!ݱW Oo<[1.`FN:D@VL +sEj@eY8+x((,;4j`EX^IhÜ&}D]OƸʘ5p-ڃ!D@Ӧ.OT)מԺvY1 Gs$$YdpJE:ϫ5^+4zS~D*b"7:tw6;c=!oV4SdpS?ScFT߁hě {خq2%σa5j;{YjB¡.sk3A˙hFW7o͡KղC":PoQv.eR5ڀNOQPL ,B [OUGy?}$#G 7N~UrR#Y)=Pas޻:u\JXMArZBÒם0 1'_iWϫ?*}@K]6]^rmc[ ? >fWM8+%z,[+NPo~0h҅}nzn,<ѩcOz*tl 4F*t"+U?L%Kz˯&&!E d3ԉU ܳ)'’!>({çC7%"Bk üDL,(I~*;|"_b1#ǕpejO!G#ъ, ֓HZU΄ۀѓҒD6p~3 ߚ vWTbqTɧyI3Qr<b4#ߌtl7KXWӗ;#|`<-D-?Aogl< ݐv&'P I_`#ӣy%6<*˻u< B#f:2WqڜMõwW޼<)C%ts_~xp1t 5H}LjYJSCR-kvx?Ub$D8_v$ݤ/9h V ]] 3$`݇Í&Sr6},sSJp{#xwCPD< XR;PG)IX!UਙFQ{dFb":'$*By칕X4ўbVփUԜtQ#[f9CGs|@RooEq[uPDj4y2)yt>XZ!Z?5LAh;c8]?Ӭ`69mSsq+ێ B/P2tX (I?f6Æ̅H 0c05FhF}6[jD ɶ%["}j0r+[{ĩ%bĈ?ѷO( ]$ѦG6 2oE_r3^PQ4A#3`b?fo3:=9~"U]%>I+ε:0j 6pr݌&h7!¦sAl󻧑+ԋU޺*~-A Cks\RFGt9fWj CuBK$1P7;~FMFׯZ+ѵ 7[Fڑ̡ABJNL{*6uo=?FIkGi{ uV+<14JRIewiFwڐMa]T[uKG/|i;ДVB;Gq5D0`3?B&2ndM,1 ;Ql*=p=V*TxÍNYI9R!Wl j2"Mˉ l̐QbAZ-6(rYFis)cw-rĤ襔-=>#ψ{NڵDLd9c1Zu&3-ijl[6m;pqf߸k]:27xsB+HfVVFVøl%G̞[Ǽխ9HsIy$ J1%o9~X{F],/`8c8[kZz!kt(EW@A 1 vm9?<ܣE*O/]JKUFxzӑb$;ܸl*n fwL@8 ^I1l`Kҕ5bs6-x1}ׁ`8g8 o!E&4sZup H,,qS[{Xn3ELY*Q@̊18ctV6hՆg1܆5U$d?YR H%h׆0(K7$?Yt[&\GTE򪥬Vy7 _I;.v!͗8-\K&VAP[6&3YmEW|sgLW60{S+.Q1# ?p0_94E zj%Fb`a&02MFkUJl@9l~ 9tna  {W3J7@^( l/"qqhL.Ld'k4fxuPXngIcO|TIѨd4WU]V4`\fCfY[2S[gr'tkVF$úz[\)mȗ` e$X`x=X}*ٯ#(ŀ$$Y,kΞh2,9~Yт ddCcȮV7=G/%]k89p:cȻ7pEø^[pG َYltYekYL]2Vg ꩖K`wWB >Q?hg)YlQd> E}R2ʛ~4;wshL_2zgG'h"Xth\tS]Qg`[u j<^SXY@?:09bȶ-v:mE'zqo^rȒ>Z[Ӗw!+;V@8ȆSmsq#3KrH ?YрI#ų!<;? ncg?ouF1 ܈@kvCk=פEqlx#"T%ꅬǏ+  aOk:,Jl {O"] N9tQ \>u[5CN)~^`?OKźO;{{mW^ӣgǧýz;<=ɒbk^Y%.ջ9r ` tNM80#ğ}|,_;0Ԓ !מ٭: yQBcGtG=ѡH/۹4XM&'j> x5>,Zρȍp\??3 B>H8h_;j##}wP'i|T'/U'聸NWfȒaNwѡ\mVxD1`w)ŸߨUL/N5!xEpi-w,MRU l D9nPlUQ9EDAq21,wn{^|T,]oR##G{TWm< $rCqY#8ob鯝wvYW[*|()%KW'<EMCgLUǵ4h~iq`*8#_]ٱ%Us ,!\h&c$/G6 ԇ8myL 'ΗԒ5#ra$IOU"e/ Zn_l[-s/zkVe\<1JRWj$b]xuiU1v򥅢[er\ϦkDgR7 /JCs+¦ap {Z[5BE\LwR[12A@hY f{tޕJeVM޲4s1\k%%[-~pY+50:]o,Uo諂H6=vכ."HN83hV_0FԴjҠx~W V) {w \QZDYy|S;@C}.Tc|GiN z]5D\Eխ2aOR~DId~v FQ \X_B<,Aռ&I K$21JE멭-/beBep9vGhP$' Dm^JVK%SnabY~l-o|Ńm=V^-!BIJ%I9fQkke :, j64oE2b+h8^/_K_ݹꕵ4MG 8$C ta|Ս[E^@uF.J{PL"_ԀӼ~$Z:XҒ0t2lS[hXt \P7FضQU\_ЂC;S{)r$* 9gk!$~wBrŅ>up+ERlnl`Zک(//g ?YPW7 T ۽1֣3nrd]9'' e&1ѽ7̆p߹1\;IG-kX'p$ |CK(o;%ASV!cV-WXhNNFбdlOXG!J{ʤS*}2T~闃*Ӟ2uz)峼ٽorcVD>Ԁ,o-Y)Ю_N#?:qq&i/*k)̄utzT|73oC(N0ԿxOV͎,j˄\Z ^cQ~M>ԋ .&lLT]AŘ-IZU+^2mk< &s#kZ,e_w?ut` e6((NW`0Oԁ<"䪀R) H.-_2s~K-hPҒR'k)CiτIhp(b;" nzVf.Fغ'OF-IPRb hu}R*rx:L/\zH4c;~3rwtƲٜ*XWf&=~#bܛy>++i=}k9TY^G7) Ze[UvKM|?<ZLy+˵1ȋyL@r?T#o*R82=Isʹ=!|@fpt3.wfQRA)bl ]b 0_O ^lfR:1Ŀ/i)FvIG(b 㪬ʊUW ܾ; l#]bb?ݺ[97Wԧ ?D`Y^h4|K#LI˅t)S+ݚ--aѫozg}tu{ٛEk7lƿvd`wO{g=&9v|Y!==mw{۹\it1~l\A"$-/gNPnoh>BR=ްʗGRÎ9 K:^apd1[8=2e%ֆiy#'H$rsh۸vBmU٭jHSbd/'+Qϓ^A+%iيь1 CSsz.ueMF\Tbgx*R+(&vJr: ,g׷{pEWXߏEg qw=wqJR,P|nJH(- lI8]ӻפqƗFKKC'‰ {SцQ2E}?Р-ϳ)CI~4dLå ,L(_ъTQHp;޸PoNr(+ {(gNBqDߡ@Ѝ2"ڞ_OMw;I:mObp9tHf)u\qyV2Uhhu*h%-'ŵ6b3LSV|GC12|F<:d?Xzlqn$ Vh<K`! 2V[q16"#˳յ4sJZ 1PZjǣ2p<<]-F9{\m&mE?wcR H,|ekI"mdIBt#HfBШu>ʩbVntG(-'GHT]xM2IexȯUqMӮJVef$7;cjkh>jn apUɶ;xP) [2L-i{x; l[i!&XpL}#w5Ϟ"W5^Y8%kC+2/B*9~8j(=7GWՇbzUr.^hfkGrVrus^)\ʁ}FzD$zhAg-8L&OH"rɛtoN O{1xd#Y.Q4R}L~} O Zw{zt؃wovOG{rv}@2}7Ryx?8:wu'z~O7G߾O! ;_$"*/# ^LC _²&69~k\uyp[:m).|ڀil wZ;!cxw?DkYG@0nNNBǑ'Q3>x*kAg@6 R(uygMx >m%)4ty Pw@"/O`#dl^nћE[Գxͅ}3}@V6LE[$4αjjĘc2RIdEؑafs~t&bɍ+Rs0G ٩co:ݽ0ٶYcY is[X)JopQK7b7͊)\[pt"vQw) 0^>]ç˰^-'Nͪ4aH[#Meʃ(,[WSݨ,/Z}hPWrF"]nJvht*ڐ ž"}D،e,'3oyuYϟاBY]2޷r̜oX\-_(U-(ZQU]XYdϤdPG|{㫻SuG<7:B{*Q=mHP\\ X(/ќJ1 5?\AcS>jl Rxi?[6_ܮODߵ|t<74w-`"h??3Kg l^ưeR{yA|<&%{ ͔mcjG+"AT(J.j/0ev@ϊVWު+8ҝnwP5Icꄍ%-s5ǁDa6` `sv K˿ooL+}X 008 5cK)]SNܼ80?|Ff1̉-t`< 3U 17?xgk(0WDlgsNbQmXD6\ODc] 5`A`Ʋ|c^h[⡢ƙ75[Ԧ?0e;\bOus=2$BF"q`f1 u]Uuϊ>ӝmGX쮖 )AIXn  ㊖$+#Y|BߧSL<@'>jd/7yzX7Gx#d]۵~vJv58_N057:h"3:e\"3.j,Xp+]?GBYxox| tF,uZcPϚJC2D +d [K޸͔`ӪMUa M+o[':P`LZ>P[?2v_پLE1 h*?}"|"J[m+zJ뛳y {^FZS2L!g@Bf)'\Aˆ2miG3' z<&R-e6>8mupǚ0ݿ%񐍷 7"wL֯CcB՚5 D9z""LJ|PtJ74?17lM8 ׂE(uky\KbX|FU./e/}11JFt6.<+E kF.k䏝,81$ lh'o^4:nc/ٚ,2wymgHË0B(XomZSV2>h &j]'MظYQA[s@~%RSn?%։1E7lg\mqW P?i0ߟHsx$̫6G3BPzxb&z謒鰫ś& yQd|5VUќڗ'_+𓋔f۝e9XPJv"w3̖SOz3BaʨdB9ʻ ˟̡F4` bm_ lTLn9B> ю'G nha=ʉ?=mHXġ1u^{ Ձ+nF)v6eLΖeCjX:/ ɟs#k8; o]D<űp'`n gӠȳoh\}zs ش7.MGE7 bv3s>+>X4Nvw@g35`ɰ1:pe nxfzQΎK->)BYmr๼\ݪ%BJ-|Fj6> s$-"'|{r4!;'毐U?X`; {\堄b#<ސI3fk i [ɑM7힒5?rw귎T{udiXO-t?F(l4K.'GcE ayzә:oIe}si(P_]ܫt(N#_VK!(eɨpmdP5ܼ9ZR:7dPHvemG6gly mX:62*`꘻E}dv :NW0N(S=+aԀj&)P5 ΡXy= 9Sl;KgoN#RU'YS^W/l%si80sJ"5c!CIfnmnYWNLۉv?洿}K|X,nw ?g$/f!^J7 ҭ໓ɯ~S2n|*_%;};88AvóݿIOaDpyL+1졡잞_~9wJf+YAӳwOէ{;LGǻ(~BCt)@$_nZNߔ|C;*$QV@ *o/C^_L?ʌC%5& u/: 8 0e~xo(W\P@I6RM9ŧ[H \o0&:Y *+ xr;LNo_Ri޷0XF^+2x0#VЀg ۵h*q3i/9MPXé(DvF3USf-Mi%bS{'i9SVT7 YdccjtXU-֔6^Zx^8`6ЎorJ934"}E@3|qۙ*_2O-إSj+y-} ܸ]m ^JgFe9t}fW>anl:C{oO{Gwx{n8y vrW ;@?0KgBYMYUG2 8p@,C90ϯ:&mR#u(I)_((E8r9w|Ny/4(^M)n޾dbxӄGE1!C7(bEFl8Z<,ntjh#AoK c^5 ӈe>D18b5~@irj!=:NkPbk]|Cz KW 7@0Rxp}![!߆HHL["uݺsc*łFml xwacM k}->I>.R~BMm[dg)0Yq9IO̯7޷b,jh,h Cmjd0*Y%l| t51K6iLgd"&*Ilk,[PvZ%~GRN w= w: 0Gz e@/.\"M <6- yKɢ _n`^fNZ[}*;:1-9^?̃H!\c[wc~}}qmݱW"XfJ G ݂$ROd1.nȵBjSy YW*(h`JĕH x~ygF 1^VUv>@nw!G8Ć% D\cW}@_c'^&c~o w?CM2u={#pke:skis3od<.FB_/0{޳-Vx @S"~կf zJ%x%`k 4(U:T¬о=A0è?`_8lar^‡ogzloc_;UZ;$;vRtWv\_#t(eBlIe$??3_NUZW~uOØ=F\ @zt019jU9%vюݷh_mD|zԞ:Nд:Z Vּ*%1l8E񯊂/eA$ pOtKt;ɇB5;ݒYa%!],˗kPto7.Ļ,>6΁P6.U/9$#;PQ2I5ͯ%OVfT\/}!OpBc^tufnWUueIq1aɗEEҼPrG/F*l*wW?ufҺlxYxv0,IMʾ 0mLEih*= /؂;Ӌp˺l]7+D߳Lʯ_ ȶqJyk<.e8VԺ/ȰqWf1fp*yT)95vsEytV\թSS+.lz6cR%^uB]S L),q1&`8m?6X%~sPQ^Cvhr"KݶdpӺc3\"u P߀Lk2Uc&+׹9tKERWr~-Ol$pPy2.*W,g8JRd81_N?@n`c[rE[NLKRpRih#DViF Qc&4_BZAP<2Vr֫hl+( sd萮3@JQ,F5.oނBι (R?.+zt#= ttRV[P" qR3.kq4vm<4X1+]ZS̩ڭ`t^w,!T)CCAʭnIFbn9ψvVHk` C:+$B&@G^g*g˰!Jvj$%rYlnJ*}tOw"nVQXzgUWBixJt;-n!1NƯT?ke(ofwׇw_Dt0c:ཽd6)q_Ѐ #V%zh@Δl B``ʸ>cԯȫ➉W=_TO@?W¯mŠVc^<-(VH#J6ZrMKQyKşON7;EV<=?&dj FŪO4> -|fj.9Jǟ3TLas:wo1SSr3Fw?vjw݃ t#Nb . /:USTն0jL0vd3Ǖ|P.铂d%L?_8&t\G?Q:AzcJ~N6BO>:w^W?Z ȩimn؊F7iOnʸK픣RϷ[%p<{z(*Ai!/DՕ8m0#SN?۾ՖK|Pr"^ ACM! MWsY7̋f@=d loeZxïoTO>//+ jb#gaE$C-%!vPWA꼿ӛ>›!ٙj,֖fAwGmcVPRǺo74OʡYؘMi6m%^&Gpʀ۪PO2b;!Qk鄊[:ۦ?:U fsօg6U--L9wT{v:[E_On~Qi٥8ubu,#%*7DYuɼZa6.H&3S2Vb> c<@* b[\>kL6&6KxuMC~t j[XIS/r}"6C48LxE{DE |0[r6֧coe?(zijTѬ-:?1C ˡ|KOE"[!)Y,bIc}>$`1 VN: _aga.h0zwtj5!0Kd WQGWj k&o(ȓFvw]Po]#IUaaO@a=Dt2* scϧxLÏ yAfMn,R4Ch&299G^}FQAMlZ\t`w ˠhLZWTQȪp3vT>E4@Qo `65>K 6<~)N1ٛ< f$ecqٕνy'm0 Vb,ʠxs&d Il5)Abx0qGk$E z =_m 17~q9- F+1y.s71xDfՄitmTG܀53a{MC3ص1.&1У=a>#=lo im_jykdW-^>IrjZAՂduDhQ*%=5ױtE[neN~lV=NJpQ18U>Q7LnGVnUO$ռ?4}ׁt/2 ŵ^UN烏`]=|F"EWksWuզ6S0">S$(`.@1m΄EOuߖ hMkz˒vͶJA1 !ި5NNOI~)y0a0"e}`qz"kß%b<BY(.`[^Pnoڍ=p'n[1 S`.j|i||ҀSwezS{}U~=kTwÌ75#8(pmC¯;+5p4cSTr+KEA͘4/W 13"ˉnb.7@0O! 2?ƟYܱ;IΘL~6oΦ;NUݼߨAr <;Vݎ)Q~"u=. W Y.ABE)_"\~UW%vE/‰C)oϥTS'ЉW):3ڌ{o6[?Emڕ.Hr*ѡi 9qn0b6~3ÈU(4'sOT/oKdeTv!>0h%&B7h|FG(ΟJs8f'$Ac="6=_nVE}?O$"fM@T£GzWQowte=[)2Q+?Fqfcu{2SY%HŠXBδt ~g 1qE0[:ŷUE |+*D*!z+`nAkA=$++7bY^zFH^?W~j?O,U 8* )LYVG XGwLޥN%\'1~@84!ˍɼ-2 鍞3\4 4vv޴!zGȻL; /Us:7[f˸x`YYª, ,ʞ|Ձnc&w|xN]H"T&F(pZ^_dW[+LS7O]L5ҋq#K=o-lǹzsk.#d OE]U-t (Qll,+U \8r$}SDgzY,*g<#b?:Ik_IN{76!RHQqZ}9$8[JB՜1IW`p^!T~[W&~Fl 3] Pk:ZN({^$5ֽp·goy[S z>||H"gBZٛpBPO~_>78;eV=r{go/=GN3JR !P<.kL}] 5O0Ht1؁7o^W=.17>}7Zݛ{>vš&a ڄ-E,bsf!ׯjF#LPo$!D2Yq9ˤj|l:  0ysB[_jЙE1!jf`t4i W+A_˷: q ϼS_%tB9/ytP?^V}vU;W5rBZ] (QſX*rҚ 9bG%TE@*&ڱ)cd4m[X .9~cx>*Z5}xF[@)`)B95KM2huYuЯۓM+Pf:7%s;RXnSLo'/WkZkHh"|֝.64IտS:o*tƠ(f1\((MaEv)~o8 #~V81$ՠC#6S9^|UFa5X`Tj닠k8ݒ (sD(c> Ηh- - l|@opVl:@/" S H14?%/eݶW㹷o0&%m< {5 %%C<_N [bo6?1clR2(DFZ'];#:x|AjH%qz{ .l ;э IT4~mVu-/(A%z3g:xw#G%Kر|:lkq{TXFPJaЯ^+gCk/fbnoR#k\Qj let1ٸ klI SZV@`2U(!fStS" T'yD0ei47#@L5:_Bn P|Ogu#ۀb:^u.uQ{w6묫]9Iu~M*&Df !!/=fIY HV[6Kr0<^ASfGT69!X!'.EXQbqߑZxIrE"z*?Cc!:=>퟉w^՟Kf"*۟`RZyuekrNmeM9*ks%jP$܍\zMBTM#5Y2GoŨtxTZ ~raF;$ ?bBcP?! S.fRTlum z%:% ZMWiK`۩EiQ՘5M {pjJr6F/7m{L'2hb 4Zʶ0PΦǝ6%Rui-%DIiPwͅG:DYbXWpXѿ{mQv"s+H+Mkqڦz$I[j r>}>+ `f0̛!/Y|~hKd0|_ .mߞ^to07WF4]BG|.R"WRRO!#}J9;vW&3innʙo/|J9,~tsօIJVYsV̓L.8V 5TyO\kRVD}j@ӹ6{,}li˔U@,rx%AAClW;֪+OZ>h UR:U)wޤ&СߔZ=jj*I}| f7ȪK?VQMm ˺exqK.tWf~)eVCQn{~D@USJd3TĔ}*԰ݡ\BmS9ց kZzZ㱵Fmk34qz6/ڳ-2& mf쁱'^7b6^0c)AU ] /Uf2{)AEB @ "3OBt @B^8F "aK^Hd-c+h2@do}q`tmZjѲl]u拓HR֒%)%JjKvxwYRfڮX;ul\@ջ' homdQ;K5y&%GB*^lS,.^}*׮O1|#|[̈$ZW؃T6p>Q+ŰB$~:ptUrIEHavH.*[b$PcؐHNnsգ$ v`>?Zc 0=s'DgCrBߝULmyOfD;??NGE\ǶKK~ GLq>2b. /KdY5%"$:|jҪ=ذO xPf0|TZ;>7B-eͼMg ૦ )怨њoJݳ_D[/2@+FÕV57 Wl2M̔ᧉD`X9R%{FS.ؤ͛x3ݚ|Tk3W7:w>|b>m{i0oQcw ~KkQ7qF5|U96?TVC%تSӔs,Jy6ʒBWL`mç9 ys ,#^Ԙp%p>lmjKSe֞tm[ԧk{O7*WLk/-3p꯭WֱM-j P[Qb_M'3$:i.}E9,+\-6B zim+eF]rtLc{!9.Pdeo|oNZ@奯Hi j2)3.fɄp Pg:_d o\Q GYIZKuNtڠVbnyEZ׌uXw!Xm[\-{"oY{\e6GF/X]eo=+@Dbk Ͳ'ۏӇ`:k5w8چ㐘(q WmK&JP2v#u WD?_(7-ۮE2k; HMun}lpl g23J>k|(y9'[Ye܋;”ELlo1#nCw .'sAUʗujN;VQm/GUeu`]\ \9_{5mߥ֝rܩSQF/CsY/,:Qc* Jb}c1o*M %2~*4 T"{ o+[6T73I_jkV$B@VF6"(@q֌ا\tj47J섒 ȼ򝶭fl QAWlŕ[^ ^M/ɻ+2Hl8ñwpPTS+vqiN`HR`vpt=㧀,1 n`xc߄Go }i 'V$C#;0[:qr$ Bkx5_m@-7t#xa3tV5bjhQ|#:2;H{Z*Bt+rx |鎦a| ,|"'.g*΁Ǜ녕ضr2DжT D6-on WxKčR}AQ]͙*2a1dI4$3>&0hV~q.q!_h+ݴ h&Xj|! *n>+?  P4Xȭly``{G%vsa55WesõngO'ӫKqcKmx)uߒ (Hʞ͋{1>/UnAGhAyB\Q!kyoYUO|(;we"a/ν6'z ovcuq0v~}[@EhPEbNoHX1 Vξl3l+9MxjřgپTmxq\GXd72dAN dq Ǫ)wtǾt{9Y+_siwb۶yʕ"uLQ#M!(wхlE]3usLU-wvif36֫ź:U3+{sxL ߃% Z_~lɖy| Zۡ#0<"|tNկU>'/n42o[gWj4@)EڮF VTztzQ./m V :ij8\݈X ЁH &8|hYrUnyٙT͏%Rsy.A|N1 9FY v|`%,Tɟ㲥6:?,-< ,[qLX/RQ ,M;)9SKkbF X~`ѯP@HFSAL Mgt\ tgg0 ?z-g=Ku>x"T95D˩x0K3G"=!)al *JnذE9QLjq@Tulf=_n0%<ӥU/V%PZ@bu$:W-*T9b}>N:0xȒS ׏Sr~\!Ã\"A.7D7O%Nyy:tq47 |ɓ"\\cH#@bZ*2gq΂1euni{{#}C/xq"UnC8.@qzxI[;0ߤLŮpP=oűt7(E\~$Jcv0Mwo']G]*_M-Yk}IQ @3|I?Ճт N/TPS\.oHbI1uCŠ?6<92։9~LrÊH. &9s{&R5tr`Q{Jk$"oD2[DZ{hÇx:yAy7"ݯdGX48 :[xރGãǰD/?8F ټޗ=|Rp:eP`K3Gуeb. # pwsw9ʟ};\cиך@C>hptwXK#RVt0x)UIAH]J ',G/o80Qa>R ,Z<Cxz,+=;u:mnktcTe+>t x"t-̥ͯL(]5 ?e~eWr!+fS)0xN~wVٺ+ (N`Fx~F3q$1K%8X ̱,.q1Eq&^&%;PqukF=; u0'0\0#LBxX ־8Ç=`]z`$CQ\ UnA n/re},\U(4A7q" [) 8iЧO><<3Qb,Zp08h JxEOݴ@!䊬v )ElW,f[X8Ax%rA pջ? wуGzp?z]֫;>}y  Fwh4Y_CHd <::/) 1oAPgCz uJL"ӸO/U\oeV^v*Ƃ+穎]{EDg?yIum>fpmK`6ir'z 4E&Be$(u>a0G ۮaQ 5c&oib=ʔ*$$=JCIc7iTJw}rx'`M,Q~L76 Iyy#BV!S {\Q,__w0䳔a`u" X+ceԷ71"IYv;IL CӰ'>PCY30LpsqJ+Za,ΚX)&RSn7ꭤ-W \dHGv:xLۢ[.\n(ndc}GH;{ۀ| ~X} %|gh$tn+1ߍ;y/iSax"•ȅLJ”&LL %쀭zg4"<"4fRL(9/0ik9w/f~nn0[VR}F`4c9mix"( yԷ0"0B&Ё>%-C86RG%uNueGK 0W"8OҼeJCŢl$&=b5aw ȏN{~ m:e^Vu&dvKYSlLΞ~2?k4 ? 0ݫy5UnܳbA 4upyҁCώG=<>8u᳇^+y|.SwkA9`ñd5Y1gepDke{4%ÇRR,HtbXZx,%w;@S:C?F} =5Hw_!4,ͣ[2,00 g b>5ASơWƖUʼn ݊+PNc&4#:.oLd؏MxŅF ہ 19˕=M˗ cg.ݸ6g)2#-n?$%%[`8;ɭчNۖ0d,9TՃrX;`D qݽbYW蜭s,vmj#u'rQan̎3<2-عaە~f|2aCۡڇs/@o92X-CTwx<:4NvR5.C޴I˳DgE@g + g/;%&%IVZ}sAiĊH335f(E$:4I5# tdls+?PB[J-٪mY/*Q 0 G7$>qwlz {gj-yM|uqtqc>zTBr`w0QAbk`p+ @Qƪg3.E򮜱bJ)>;~TtjSI~FxtSc+o?p`^phC[='%(x8L.݇(1/c8!ň޷ }>9Q7m!,~+<,T)ޫ_C߀&;wঽ?^:?Ӄ=J qɲ4ބ_99zN9y>~O?Mie&E8@mwH%ܠB6њ5VeVդ! I8V܋OkPf3.e' }ثYnN/~r:ɠJbUqVnE!69g%F9Աn«0q]QD|sCrP~+=n.՞j#U틖j_| TjOZ=KIX䥸.˦ϺW[Ykw ͫ⭰һb#:Z)#e@.}c|A+y%^]DRDBg0I2w>8`h 3D %THu ܌1|\ n2%9ϑ~~Ov;>=޴.Q%{&EsDj3!hVgqyh޸O\yD^U[zϭ |ܒ5wdBoo+N?;ez8e[f"MzAG/GPGu0!GʈtQ".r^{6SctNO#fɀ7\ xzL7-0̀G~磨އ\~{~܇2@rC =ž+`OEclosXOO9ror5L9 :BOrh/95X5m#T5x@w¶h}(qҤSH4RN41Ú'&YK0ԎV`nm4 -\imBvn +m@.$ [ HlpAMZ~x-ZgvV5j5jzpJ:KM}N3p3yf]l99ߙ0+<|yj5wkߩ5t~wk~37'1M<, ętc%/}Ԛ.=Ti<# isջw]P%G﹣+-sa),4iAA:_`yaMBHGpCi{K,bR*.Nr+;u[Xs 6~Ӛ~k¹F%Br,Nڐ (3Ae^sD^,mnU]D+`31Gx<5<4.-a}GIп}@' =)KhX*綛UF fQ7sκ[ lݤ[ *$oMz.lyDETv~.i\Ɋ)NP&wTKl gg]Vꛢ)7.sK|][ ?泪$fiWSmfM'&4VExH+0JdՌD)W>IC'iC_5+iz]̆>X֛][6@˽FRBqqL*~GiwDi +FEQ9=ipxH)CLR̵-4֔3h cVޞu5 W[VntgaVCo{moћΨjgA턍 ԻlhX혢bx 2ƻvae*,t/-Dnnu| -ZCFs힉w`RK O7pYeeϓ瑘!y綆 [n|l}m5]a2υvzDf1 ]/1̄Wtw &c];`&"ԛ~v^27/9L#O RN>x/(ဎ ߎa\& )GO;1DK Kj',?sWgM) {0*JYxȫ]Z- 'l`kDTONPѕЇ H1OE*"Kut!JD)T{n~u='NRs$kc-BmkXf[BIK(C JZBx;RĀ\N8NhZQq H=ɑH=Bv#vi f<, `]}d^x )I(Y= |c-غ޺rliADw})aeEs<  u7[1_gsWFfss9cm'\.j].`34] #"Qb45۠o*q *kUva+[x^56tî)jP\Ӗ_cV z䀏FlT&c׼pi@!)eN^R[N" tĨ2%2S;XzKi/[q|K+]Vu1)J e] z~ƬjXPi8&z;/ ZY\X6X Wjx+Y E; KKXj[̔ a"Bƨpthp-T!T9:=娅Oݸ"+ٿ;Q>P|v~3_MUf Gx$mH'h޸O51 M"OGXif/un( t8+׊W]Bg{_nҘl`^]\VWC0^BB:n :NjxRb o 2\;(o3o^gu!*QS\-&>D?D{_$ǡc|5U5 &3E5^h2GhoC@M \D ހR'KwWr 5,ؘJ!8Pi.˒6+x|4> < 6ܐinHJ  LD^vEaoH_N/*W577\-Obt.Cօ zd$z.$pP~6[wޭJc.9ۧAVYo_M1o^Te вPcyFG+z[ Մ͆ OX1A*vB/ }ǂva ċu3xX"$^ɖ%\Kq5̑C9>mEu-AXT9lbkI6ZbY˻CMg%<WgNM{Aˊ1=#)5T=|mb2 ?A$xH C^Oú%~d'r^=vD;tn^\Kƈ%d'@;#"K{$l2̾f# >Wa nb U<\*g/;gxBGGoT,U{$QoK,w&&^+qR^7]6Q$DJ8D0Zd(CE&2^DXfFDVF+Ԅ^?2Ky_~{/omZ}nܝ'Kx.>.%},n !yEnPL[x*#(RLp"?TX(MEKH>̊*s/dT^Cz/%$嵢_^6<0NR(O TA(R]x{?Gm<MPeG𚜺!t>Kne.:Ov܏y$R1uc J4~x/O,AFD ^|N,Gpوz2~n Zk't۟G Uy:\ 1Q.ќNXTBRM9< $`S垯 NG $dtoqt0s 3vN\I;kfLJWO{B,*ZW؞!F;V6Ӱr-,m%[ ]j;韻njc'.RAsU1?ecB}J?< jWPT8Gl=R!hZQiն{E48^1Ng;w/\6=sS!-|ƴCU%WItWnD7`OI ɞ>)me%d[BHOGaSkd4A>WP' @.DEr.Q\zҝF@[fHf :,Փc{T!)20SShA|'HOPy `}'A%ux ՎoQ#W msEQu 4: SiǤ+).c4tҮ0}gA71vkqL9M#U谢 t7h0~N ޛJ+!g)` ܸoYZNː| ƾ_6}2t93/|[eynQˡ.:i*>_VhlP~(kq~%-ă*QPNr܋H̒Uc+YU5q$+F(+EWeR^JuJwmQbvW<{oKrP,&%u3Kc(9$pXpklWYNԍ1$ްI'A-nOB78?6Q?V[JY][J9 уɫ$%Z8*IPG]jSK u4}x7U]*E[ƽ aO܁>нSW;0M2ë-j@I OȀ}_ϫ_aGaf˶m+oΐWNƲ (Sz "C*zoa 趲ҽ։ F"P8D.&qa!d%X1 [:T@#2:^0kAT۪".eu]I d\-^y{0CB+zj{UaB7H|\Q'**O]ueYTطMLZ9'5a"`@\FON-[ pvݝǓZX,aCg9 ݞuܹ^!Tf4 BApz+q%:+ƾFghc[/T=ť ~`dϠo+h_l}o_ݿx ]%? ytN8Y/c,sp=?W4D\\4ᔊ>" P qPs50"=mEЮ BupnƯj[sͿj+ # נ "\ʃ )FL4K~㛽[-S\L =js/cxxqZECbpmPlNk7UiŮ չ-Q 8<^J05  $Oj.i[p[ 'n-]nga2XGsq*\*GEi@;%l`HB!J܀NWfyrŶt hk/,UR]߈:r}Gǥa Zm MZr+/dnxBvyNZC]aE_nu.#[FnP.V#L )otI"Ϗ"fE'/N vwn{gޖz jwg+<%%n[gtЊEc>ʃB;qWxEWZ89 &4v,QO,ӫOejlI64۔6ۄzQU\$NPZӏ֤`_V2DoXSf E+Fx͠zY@öu7%1p>0rѨDNҷKl]%gz.L[Z-16L5cCpdRPP |lJ&i4)b)>0ζ>%0Ўhænb'MJP(O3]+}mڤtߦ4FIcL`yoxMy,|.DU?~= ϭU i B Й.L;n#k s76+\zYRȖTjhiMU]iPm)ۥ02VF~k8Á'rwU߄lI{C zVf@]ƿKWI+))Zy:ywf8 dSвQ/ %-ZV 6MYs~a:R$K3N[^5T'i0r{`=3n@Z X؇&қ+YnbX3@Mא;ੲt|-2yyӶ ^QڼGݑu K[2a ./pP[%2,k iV#qrq-$z(It_P' 0hnAcՏQp78gu~-OfrZt/U67XvaP\/eI?uAŹFnӆ.wsWgQIipbM7{.1 I1.Ge^fzSӐS4H۞%:)+*<[t⮯Ku8#p)ysR2JЬ"H xPCaHQ4 |]VеB/=׷ؕrg~!Z-^d*Tex 'sv ISҔyt9rFi5KZL :ʽ&PB]ݕAXx^w>l}ޏx{Q1UYrW!ơBY^{_KZ%;L p+ba@@LH~;ǒqfVU+LX}(=@mɸJ{icoT̤l8^8k5פ9[q?b4E 9 */'#+I=klfd" 6ijv050g〜 MjnbþI(+E*0l片w:9zd| y_{4JZHC8CwQPf$<{_"KAY ;xӀ+pacqE ^eϘ̇; &;o.ن>?\hsoٽDUw,O۲3cbּ,[aAwf J܍{\t"SN: Tm@4!E% prT<@m}0:QBPR~ 0kx) (Л孜F]@o##0<v:aWؒr1I)D !:P+vD?L3 ?oV2E WLL 7LQA45Uj=w.gs8+aE7^ǑvyHW+Gj)פPt 5&+9Nyx1A,{N" 'BR;!# \=ch:#Zo <bZ@Y8Maf^@~FiL_2{y`FCRO BjzjzSyc=lZJSx+lqӁK;A͡)L%'t\Y렸FT]ٍxֶCm+w1'C NloˑhhN ,Bz+ES=<gkKCrvKX^|A3m3ь;cair52(ZQZNi\iq;./;MMR?>C0 JD#,T)q\CTS )%g/)zΕ%%EY`2S [2rc 3iY05SS'iI-knc0lq6:|^:~I̞12u,ڔkǬ=YJȻuQ h~`d8GP@Ƌ!|x)i/d)ÇWzeQ{cL&.1`zԪN^Q:ZW֜eKcMRj/]ѯfIåSd ^nz yi(^&K8k !Wu5J; f Vµv)tgxK{*c M=O8N($&%$xrDqmaljR[n@N<8ҌNQbbQXLnƆ"Wk%ɵbˆj;3j:=rUqcI'B%M$-y^:aX!5dbVIX\<Q>wa  J? .Rz[iXw\X{\vo.P *NUgb2lc-+V(ei_NXQkWhl*q:xs/\E9,Zx xEv3kLy]kv]UwWJ-6bM^r}=LnA$삞kQ{PlHθuEq)vZRrrq,䧥{{ڵb5Z!<*AP)!j&좔qG@|^\0~b: * sBU}r(a1QTb`B!:x',ҥ˝RWvhݛvglɩ[uQ<Xy6MO02Rz(h)D{gyy/_тQq"BC'CC+V49J @6@mDz Fx^!lAIs:pE28;d|J,ZL,V #=IK\/..y ,lq1Fd:G# x%mUoMc5sw3d({!cHN4T/"ҥa[|0"l, ˣq6I (H7q6NO F${ }A[cԺ`^,0L|@W([j Cpb-hIPbDfj'חa(Nh9O,t! ݚ0 \zy;T[Nʲ1".* TNZ…T@JoED&)ۺ81T"3h*޳N(뜯y%Z$QC1j 7Ce.C'kIJl PCA[ƣrVp\%.. aAp]n2?.9̷_z(j#  "!GߐvȚÀ㥇y"a}Yخ@\ C*؜HkѝI>`zkԦIa95,Ǻ$ThU|$Z[n"q̏)HW2T%O0IpDQOXOADOQ%`.IBnXi0d`0&2fnZEp{R]2獿4Vc$IjN_8ILaPXڰC/yqPɛ"d( P&bz#ux8VmgsJ\?M,Av,jrWg;;/oȺV7M"܎+4C~ &t,4BMnGRWPī~5ʒ”2$VK'kNvT8cYβR ui7d."rWSRL=@*+_jFvUSI^ *%G2A,Ǥmv}a6/χӑOQuGpLZb=p1[{xi2BdevweWX\-!=Y5` 2Vx t ̲'5B:(PT L B.+W(I롤 Wٸ6*; l|SFiI. ݸ/'9e/2'{Q%G@W4q?B"X@NQ~壞 žS}CZSp~&*qk$|ﰯm|~O?<+w9߷S"a^uceR0NZC@٦0eM[%FcYw-G{+s~}L7V#O]u@KeE%3ކ#Zbx? á#f z\FW-7Xz{ETر\O±VAf%,vF.jTXΣ^`/O`3(jqهWnYR/V_mHiX!|@wE ݊緝H?s Κ4ɉnѶz˦snxKOK">yR,a^,^]_vVaq[uo]#i4C{W>xZ:hn -c`:-.(3ݲYf-%T>fZ0J}DpʙRã1Z_E*2vy?j} =d&7XkGeh o <$` y<%бR~K9HZF2fdA}Fy-jޜo_#&|:3eԈSdLdߔB4p;]gmZzG] gw] ĥ{ȤL@% u{~;$@fV=2,&W;]בs ) 8?GzNwsg؏n*y!6:O(J./^#Ok YL"݋~*W7ux."%TYS$>S3yb\DqSzKS2$E̓yȏS5RafdYG__*"BgM{4勰&E2DKvĵ OVxK݀^fPҞJS?4T}yvUS*LaT/ssMO{ѽ_Q׻; ݥ9}Pi|~(XPԋO'0%7D@g$i8r"aGS1Qc'R|zDXq fXNr1%WZ*QKη;8 >DQbP?E+Tĸ޿Ѯ_]EQ͊ڀCIMGKR4m>Yl0{=JxgIn5 LF*TBt6kZޱ)dd|<4ӜʮqK$MP/0Pkq,2荺~dS\V ZA[#&$aOFkdPNzL05Lf%]+Q|ƫ]$pZŖs=bb '0$mA3 Ȅ$r1 Yb]eK`K"j"hAsmD(erQ%/NU\΍|Q]٬}EZOxsܘ񼥃ljWNbWn<"s2 5Ý9, wz׊pҗa%/|v0|2j]…#|:'-h2T[/|9׿_/WEw`4V (ܴǦ[٘67U% !Ь0% . bDw>iwM*Q:%"זD9E`U.E@A Rh(C2[L$nׂ8Rt+!TzU lvEK) ]k9'Ra&vҾO20) XmPQ@Nve\޻l7>⛴Z<}(ɗza#Kv3xk:l ^^{Z/_^ԽpDL*t \lk D$Ґ -.}$6usg2d,LIν om1/Զx:2, ׄz"+>{M ?.n?h8n~,]l5 M,V]ȕ5bMOXô ӂxJBT-@3K >NXn([ũψ:l&E[gO*Z~?ע$F^ 6+BExgȼ4Tu`Y2tKggYHJ^b|4ͦ\4~ CHԣs'0n/I.<?D1&SgpY|](mftt4jPVaTfYfMoGȋj,KTH/~NCu3Zfi)1|\$LXVPގg~MnLʇtv3 @$kLС 1ԞgytWcTvfzvt\ql:R:ɕE~QcͣgF:iׄFe_;y"h)S`CSeCP9Lo YNVhEyO^_֖{q  3@S|t4&-lhb|11>ML*H 4M 5kGnq~FYV[z9~m|Ԣ-OQpbZ-;`1* 5-*\/}͸"3wibE1wZ{$ZVa[at ĀzܟVtB`aDUW{#gyoOQ8~9~8nGi&2~}$7AQ0럁i2b>5G)D(A)pvS/;efMapƨEk  %GQ:G-YIkGipR .*K9HIo/XL_]\\`r/pQՕ}ԳSSW!f{m;ecc\,7W,owVWV.N1$!P׵-Gb!(U_'ʮ|Gj$ =W3I+(1Ԙ(o#S捋jΩU_eS[,XAa@ih-Ƶ AOETd;='sm~(Q" ycOmwGޫT +ͅK #HI:H >ѱ3/Y/K=/v4ԫG-mhSYygy_rkPTh|js#J q}4= !y/a!:VDNMSh)?H"r PɡvX#-Xg1xitƥIJ^I5SsNiSlHФ\ >6./x4F#\xNPhPTt^0tiHڱJiUa?]M%>u!62~ҘW(OPu#*r9mrS'< ﻧiU3ݠs]й5hAjI Ӫf3|lFBghD@+il^%#e%V&ՅNXuӶ0$vl8%uCk~8'gK0e8p'Hr(~"{Ix3ՈxWENҖcbV?I3\4a$Ij_t>Җo 1Ml_*=&JJYt&)`^Ԏ2К{Ӆ s*=߫vQ*wʪjnʽD0.9 _E325,sQ6L/>nu;˪1u\\'%Qa3cr ;˭cy[m>vUM-41*4[:.B7ߠwF(6i,f&΍a2^i ,a@1dESP  A@~N:!e6XY&B%=@JϏrN8PW@  ӬV ^Rǿ88ahQzfQ HRDܯE6C` @Ugc{1s1wHouGAUlje& 5e77m@9FBՁRL!̗Q|ܷ!ZAO)K@܊SgN's)T_(v˯}tJO R"e%Dk&?WtTIѭuU[VT)ZëwiUkL- Ta}jK+a) =裞Kc - 7Mu+2y`<,O2_n\ԋ^IK5◭ .o]HE,a'pYǯjXuړ?W>N>N?͏]%ՔBGs+KAbcZ6j1oGHVMM-9=JDwH@{oj s9Z{/]SI@?#'y&ȏfZM%3||N8ELɥ>%R]FϦJ6ڱt(Y,~C7}8\¬\xg~ fU?hW ][nooo[V/]˲*9==mWȄg¯?`v8`|S09/ڮmMur_]Axܦ2SNwQ9^)l"Wnn-|h$hLeb#aqJ¤%GMUZ#ŭ݉!Qe⾔~]~i+ G&OzሽM>4+#tQZqd|'vSh`.0ypp5FL Qs91ϭ|| ]H VdցL>fK)j1_I]D*V"Wj 4k]C_•E " rh{ĈU_4zS(\O\ڢr“j:OU+F o\@) T)aU-)KFcF?f2'_5^_^y'yC?4 I*xJƁ!|Tn+JԮ5Y%8Y@;$Pr4Qm!2"1ęH9VyL4hDs(h.TUSRXH- sJ+F# 637x+P0ffʹV%X)HP63]PuZcNdÃ&#썘Fawq7/{{92s?=l=awB\eoEW~ɛݮ[z Ju/Vyɤ/(FϋT:5͝<*>@k_q9bj4`[5I; / MW/X"&| QLA{|/[^Tb{[I~Wә[ŔΕr 5&}Bܴb9qV!y6D}3c0#*N!Yw;/D#P!@L[*!Q+X&FųH1!]4'E;E~u.BA|oa4 zn tpeADdj=t>8w"GD:y!da11A,< h$ZP9H[#gu>zɱ 3V,b\#8(>VY8 5<_iC!*UG`36Ԭp7bkYG^$;DIt$˷l?Q*m\I41j"1fJZ5w ˪ XPHb{`331ϰ GY@wXԝdc%*7YǛFd;SA l'1\YPe%X 2|s<<4oeɪJihHULVYQUw`mAyHOݲd_[ gB4M F"l>Jjn RgWK4B9Z?E -~y[UԿ7?S?;MlLy' !$I{h"Cyč ̯7*!)߭)tҜ{ b.n@{ԌWC3OI#Z^^kuɭ)Р_o'8yE}M5~^=f gzx3zm4t 7r5 ė,΅klN R Uw-.( Jhp8A ;}Ѻf"pEkCm\ Ct_7$>^׸%=2<{p^`Pr>p>Lѥ26=WkǺyΥ1GGT++c)|+3|@opx竱8ng&1`yFLK~ HB @#r'@w&;i^f9nT9j:a+v%b¹ߵ:NNL! ׀"KGcp)xQLx H x0gΏ^m:sַrSbt @5ZBsyj)w*OT$#`!Z[3'1%s#hJ?''{SHʨ*G9Yw=DxhlՄ)Q*zpө`;VN=-މFnˈ35*' *P*myTpԑ:_"ߺDTv>cUsC]+]jiyBBѱ,9ܺQ9c wzr,*o~ꕋw++'6 _^vgw}cwiڏ(d߹Nt`/_e؏描&4e'r V3iWUyjFr5"b::<kp"eaj+d_)SoYГ RYYHuB_?XP98NadBQbDQ6{/i>;Ю? a@Wxu;'OM%â繉ʔ) ( Ó!-$!P` _df{e[c@fJ^S' Us_P`88d&@]N"')%kAݸ0ظ1[Hm4V8C|1<v,u=($ጀ(Av( t~#O.8Th ȇ5!qgl,ߍ`{8S5 $cB Da;2 0AH)@vxzlYC"uVO*%[vp VA߂V"t:$1lDJ4"~ 7Hw >d(CdnMሊ񚬢kEk8gx:ooU:+)ͅp,Q\geܦUJfͣfP̯ߣ85ȅWHO> o9ڈrҽETc(BGCgUAAҽi<^/auO Dj0 UH?jh^+kG_la/^߭LD1L uUTwx'6A(9Fn`ʀ)ό\XMVxGCL$_BW؄7jلLk[9aP|ŏ}ᒡ6pK# Μ$wz˜KΚ\S[<o4Pu2/7^ot+|j:=R12ӱjtͣF#0mgꏗVa^?J΂Q@=h4POM|TD} Ю Y0+~O- n^RD~>AfBa$S$GGTfPJÃtRc0 k gU.e߸wdi|! /CEÃaM dc\zPD(؆=׹/R7R`φmB6BǷpq"wf  xM>T_[s uKt]*b8\k~Xau#-tAbֳH!Ek7υs!xw5@aFZ{kUImwNV.DsK댌.NR c[ujatDθn#WsysquBgV 4`oOvegwrT?]mmc{8s3EIc%}ǂg 5dh,rU\itaѽ<{kGWuL O gtj/y&d͉ {#3H_E>WB~8\$ Aprz޼x %)*E0,p]A w6/eIwW)3`IpxIЌ[,9PphJQGk؇UÁ<T Fq{FUt%r/B*-"TB9ͥ#e03(钰6_]-#QZ Dɰ0I-ݐɗ|HO|t ;Y&uu,—*{OĤ? @Y&KI[U8('Kdπ\]kX2c'6 @ړޔyYؓWȺL^ L\}ճxjRWgm|OE/j(#hdR{ϻsm j !`kCS"`,YF1BR'**DiQJa0sc_܍%\~@Sr eTSHBVK#6QrO<ΞkmvEA9њhL$kӭ3:'Qur;3&tLďr&\ 2—PB'穿DљJTk o9[nHqȄ0Υ#`DMGhe|/O ې^Etr%2yEw;}NNJ lYX/^gdE~=zf~]1%CX(|_Uy;C.ݝv"ntSEbre`[,wT. Pr8 ςA.l* mJ;Z\{[-/k8en/jD>g&>h^ uQ^s#B{IkhwlX2q 9kxY焂cܜ%5 zzvc7dT Fb8M7̯Ojmh:T-sR;d\6l>KǮAg7!K9ۅ [j&.Ϝ`l4Ձ,`U^h/%dyvtLe jǶ-oWQs=491F +j7qzXt =>RIU1dԪõQE3ٸa|mkl̳.BꩥA-Ҧ/qa%p]^Lu w cnGEHa>2j CK[sz 5sD/j2X3 QC6i *$>EF1d pYgKo-9%7Jc|/]Vv//%T8sӆu_* ;6nT;j(w)KD(|xG43džk"3( 4~6kJ0!*>q0J4.' {>#KϜx诏5"ǥ.0Q1c +. SrlιƂ0O&4m=U4'VHpN[Tlq䗝=sfyι\zΕzEQ|Żl0 #F\^$cKHx<&on׫`X#'"[]Mdgq]LL78kx 7@8t .($V\wN>~_$J8ͯ=578'& 5%@KSEx5f}ދ%ȡH(ϓ`L-:yĨpbEtzP0]@8d]0X Blx$:dLiuCMk bDN]?`V:?pnfRm_ţP N}51iw:_] ĘtO[LF:ąn{Iĩk.M#AWyLBWUBfW~_d;Owj_B׾DBN $̼Y %?7y'X7L~>L&`N:y)"~B.*exz!Ezj!×L0$o V*" kcQiǐTt:Nmc4jS-OK)Cg$HjQMRյ.KTDo\)boQ_D+.#5S[i5e,=sTOKHH-Y07WL" ܪ"cEXOՃFBod\a!©mto (pb4EE椽2+ԃȼ0fsTo @JY*法J禹K0b88k6sФɢ<<@cAr-S^,ź~,"E9w-jtߚ:1i@Ag%P$BU}KrH1IK+$_k:OژIJ <تħWUhUo6P 6׳Sym0|H`~l@1Хz|9Am>'Thv/ciшShXbE\CuWru!Xo?[sEtS- 0JjMsbEI*r֩]7L4QZ%3ٯRW^tMT93<` 26:Ь dta(2G9_e N/h|I\04|C*/ =wy'mT6ꋶUΧGڄFhrh1:mœ-Ʒ6U=_1ⱻxIJBF#5kwZ=x?yl##WyDŀp`ՊDqN^/ _%`SFP(PCd=1||RiJ"+W001Vnyѧl,^M:Ӭ)u?B&ndDđ:1tH"ƩeZbM WMW-[l'|[9?7ч]sI}Ř >{*tqX,)'L7IQ5T=kZBuwBv&=`0$ySt1_TtTq(4 ױITN>"oQ )1P@v(}M1EA_.BpWҷ9ŸFk7߲T>JBP RyU*ע:ݿ<=g&#/؋Ǫ&MT|֐Xe"M=Vbz\\uN #=ciג'tA@ά=<m KJTLS* ꈑ!XR'o! )uX(3%}HP!zȢz8# "Bӯb.Pj#la%8D^r(9]3GHF)|H:1Ml/⑉Id˗qBiy<'F2;? Vcx+}=&=awZ0X^1!!g  &#x=rPnOd-&. K1$VHv|\v|e,mr5WZ5v~/uOa+,CKqxwKZo´E J[[Ga %?'Jͭw ū0uClwՀ8!LhP*#*s J~EF Bl舷^LZB9lj[o1[Q -kd 3,a*qѭFPxj!zeu'7Ol Z^kAa (Z-aDsX<[\wNXP.Hc!EDkLq4ՋL۷#3} 2qEڶ^HB}*Z'p1hm qB{af,́R..,TgCs칈jܭWfNTRk4(FǓLFN(RdH@&7MZ/ᰡXMZT ڡR[k-QeS՟]}XWt#o<-8ժѻ*0k]7a X萛(-ƅ&uԑ\.GUQ#Gjsv/\Ah4e_/ST@{ӽ51 F>vݪhwW5ncӛ7Ҹ,?6%Sh,wv5ĺ7g7ȟށ3C ܥOmIU]x*&\&N#z&rdA+Yt)w(3_-tb?<gs󅅛Z[,N )ɜS f`GUI.K8\p '.VYHezxRHpSmnp3PfA7F +3'aK'>p1 ZLS|9 2pn 9zf1yd &-7Nb\9i836G#b8kU"Xp„.N fuwg+x]_k# OKN"wۣ9twlya+qNQG7g ggWԁ+]!J6|7{7z%&Y5o؅iFg3k+qAg+o\AtAB\؆׭pxTZ"s+L8I gTQPaY: se.^!`Bñ]kDqψ|9&rm]*|R=Q" BÝ&A6J9d\uG*"hVnғ \QI؟'{#6Bvfʲ&6N׮/7I o7-pt$ȄBiOgY~&M)}'wNtrQ8*PaM8y!;cxqxX&Gl ɩ9v3;T=&"QՎ+Vsl/ӝ|RFXr$N^[@7Ä컌[!reWWm}ŕYЭ+\b}W nBs0 W:C9^ 5^g ^Xoo2cM~ wT.~8w̍ZugixK6v0D9&!:=w"3bC9I#TU$:q~~! o+n{dA:؛xi&(wfj< q/S,F-/Qc `~sWM ߒq 0/cUq>o'#+}5SW/ k~JL#/X޵;ʶfwn[L!7cuT)vT ۑrB7sII%+ x7-x.Kr]; 69sr ?"*fAWCY Q/E  LLN6YN݈&Px7Ypr $LpuZe[pM2/DyXwk} SR59P+HdCb9ç)Wy='݀ #]^"e_x]<^ ?NvCZ"ȞFҜ{m4X1ǭ,0 xYr:2nЇ `КVQ~y!?dB4nJUi0= C),JHvs9m)B}P|N$:zd(G d$1`\KO^C5|.䒧2ʕDjTUB"l .V/̇סSXu5ⵠ=Cab K׎ s+r [++\ެԞr65:xK9ZAsV_ssxӆk~8 %PA݀Bnm ՝5;?X6@nr i6򩡒L%g Zn c6P!*F;>MÔ]{AbGa쿋d`j:W`aC?#IR $(LwXi(k(5;xPL6X|"J%oi)=R ǡݠZXkK\n@A#XT \<ޕEQߗM TEL7rX7X2]}Z~3j!l;f. %$SdSJd=AB +=]*qZ+*8(WZWA&Q2!Ve kj| νDM *tp/Nb!ȼ1Lt&jraY)&qjVF%>PPew(EβH w6;'4jw5F%*i#>mӦnOkY 5ykDLEX\ S*f vJI"GB[1"q=(跌JLV$kb5&0̯jUePLf6] C <'Z-H\pQ#Ԇ n2.ڌVzQf 3 /؊D-@r?sSצ:(rB~{xwztzD;1[Nt|CWYcra쬺^-n!*m@e j/t,D`WӮqB] P(ťW`")]/5"x]x7i cZ ƤFB&4ClE .BMtLŌxEvnHFfg-M-R2aK"7\9) u9} 3"ҟ28v5rhn$8pk\Tn*(DS@IQ5 ^S*[){RH|ϸ6eʕBo>>s_kx865bh= s~ndC;sF R5<i ͳʁd])jUlYmn3'%~2zhrشB1 M+'RB-ͥ,Y2i]^KsL>;En=dv 8Kwy[v/^^Y̅ߥ3W)Ym!=P982.C6nִ9'=xK 茢xS<6U]3Vo^go8QjN6Yݵ-bf0sI KԮqٗA6R|o;qYrʼny ,>8C_h$jꠈ/X%\S:0r",pRݼphvqD/HrۋO,yf#xřEӗSKH s})1/"P dZmİ,x|[CRtQez]҉/RW~<ٓ/ 4 +7D<1;6O=? e)kW[#uw%BVۓb 8(i$E`I9 * @ Xj$WS0RVPeU4sT&T*)s/XЃ/eUOOfU3 ()PK Vd"R95HK)ʂB4if/Ty/)h%x[:XU}Q L<$Z+ d .J#I.μ ̭){o)?1rizP,h߼Q#|чl}">M>2=]ܝ9.26y3 sӳ'M4s>Fc8N8yPGJ`9a!| _5owfTM!i0@*sHÉyA#68cHO$v*6W+ib1<ؤ?*TScF\'"C6&@iY:lTuÃu)Z%Gr,]6V~fpf(n767,R;G\; |c.B0Imy4Nkdx@4NY#S "g9(49J_VK؟ojϡ+(uc/XњŽeN RE f~^8fz)Z\AT4޹(.) pLjrݑծG8JyjV}T!-_}EjAD˟Xu=ws7 Q 6i'\wEʹ')Fz'ҙ;ĂɏxYGq~n߆G `xHޏ 1GoݑG sEi}!83’H>J%jn6d(&A)Md& Is>Q86p &5-.gt$ԧާM*OC>{Ow'=8h=<@da7=Ⱦ΄->mH ~A94pھFHjb% r _|[h֤?w>5a @=Pi4/TMI5կa(-EH濸D,_w_Q'S[ʵsJUXl')(.L`~Uj@}17s| Z'?% K:$$AIT͓[_de۰Qa>.qbs*]{vjQ6uz p)<"GTdX{6:{)G;Q_Ƿ~>Roj,]uU|.ko4̴S3)Ža8 bK7%L+хhyԎU&631ZvZ*ï|c] `. CK:]ݑԨ,^3" 6`M4R1FP>趢TużV;kaxE!:i _y3`=0]ԘkO&STZ(J{(t: XtN~Q7Jf*񾤒U"f pvCXq/{NNXovz܅;wzwS׳Io08@B7< Yg0vSNap:<MZTsY~7D{: ;EojU:n9YX(dprz -;4Qt}]al;l[۱~:1;vh9 :{CbuOO{t;=`u0qA ;a.8/ A5*az}Xz12- :TЇ/DES+f{`N;8E=Evz2<q"zN{|lt(0+m˿=>)SU}ê@x},`Vch `L)-27 i*O@{ {/YIоG1"ұ("zEۖ&)^g޸fZ(JN#\ȿsqbߨ ~ioT8!eK> o^Z#3|}.,fհ)lL#xe|py•y<.eKu/7NN4lƁf`+*ǔZzȴH`Zmews6_#[:=#R%DǦD l7b>48f'*Ò Z?{yX7eR*vBrz#zGV#6VృNXfs_ R$&`"#1IH˛KD))q]1!xw9Im }ڲ'~]?8ҧl)eJ󤻺`a-w蹳#.4Og0g)R, f,#T;|89ȘeKJg]Ets­+~[%ѣlwNG(},Nt)R83ٮB7,4LϙlpFD>Gs{g|ڲ : 7MKF/K|ZR:Uha1N>=s}~1_,"M05El.٬1czB`"6͇idpiKH>{g uQPҥ{5qT< l ^=%ׄo*x(126O8* hg:etkC=K I 3F`5UCW4fvF^ U)_JԔ, kD ez\o eLSoԌhE@3p R!넽NDkgWCx,b Lޠ[q;3A)_ &&/ӆ .F{aڡ}Rj(ox^+ SxБ(t ׻WZu}Ks(UD$hJ@0yEE0B_$]n~G-//{6ncaxᒦ:Mɬ}ɒlIN["WZ%);i{s`6 3-1%Hz^Qh#Ln(?9HdGƪu/ "?T9 oh||`84f,d"{&GkO¦LJ.(%"6~BV$zJ񳬛5au :T IX100?MQ&wlZ]E|6"X3h?cE=iammuG|VN9--Kc<^ZHdE}Kv`5cSDEj#%@1IBlCD=AN}Ԉ;۩xgRT~aB")/ X7ikHk/;$}jBz֍!p-jiqHم~Ȩ50XhD[΋,ț<xqXM50M$-szgӣG386{ =7Wo7>bN!j~&cYٱv Ś |T#Bc )Q9m#'2n.wa!%P[X#,>靚k"e%lR&ϴpIܚ!a1cͬ5Ż Wl"Uosso9>VG.eo"mjCv#mh O>\؆:U@$JbگKLgF|{׏iI>,Vw0ԣTu*j#HXCQWШl߭&i Jpg7:`ctHXWӞΗs\ĩCSx9RDsSkG 7mRlH_/^ b4" fs5(uƛBZ( a}^;>+[:T1=;2caN4`c$\$3Њ7_S0<+Gڼ Ax薵N{/_43  ^q Y]`VavѥtO#ȗhf˞O;SFN- [@ ē>=^j}Q l&E!?|39*.$74]C<Ϧ -AU̅E|Y{Vd3KL۸>}bآr:# :7$`2[)o Nec,00:PVYC*n3XҌ^u>N[Qzx;[p_p,~nEa΢v9mf"Oy_kE f2 Gy C[Qd o#xAf?_CJBz'.6>6v +,Wtivu^L/U_V_ζzʪηi}S[nuI6EW_;]!fW+Iv{NA/рv"ڍTr'.0wOM5Χa7Ⴅ!v."`Jzà+ 6hI"O3eN}SOGYylD cBbtŏ#";1m \t[1T+\L㬘IG?#'bDaf51=J F(Z V6Dzq߻^P8UF"KBO B1$yR=ZZ33UaUԗnp_@f3';zLso˗@>>F{;*@TRt,q!L !0-D7e2]x@uy'[| MU'qZqo 1FNTAi\/VŚ@?aĨ ,ИGL\4clNw)Շ)/w7;{ω{v87ۺZ(5P,Ï$~8}~ ׍ oƓhƃzs*~D$a{gq=oē1hsl& ԀAڄI5Z0ο|=gL}Nh XȏgCՐ,FpXxz4Rbeq]D 0B5(8`htȰ*mzs?bZg8Oi Qj#b @2(R %&w\|t05Sx' x)Bݝ<,6 pS0tEȷ%.Ԯ2}b禬s+PYB-?e^/Hd?aq&u-} t&ד5x1xp ??J~z]@`BEqգe|]uڀFmxjE,ŧ Yg&ͧ{"&ī8S4è>DK%P\!fF'TqS0`OY!^xuGP` Bų¿|I _G{".nf8~l}6 to8w◿!uȁ ,:Y߀,ptnD`Rc͌#m8C˗Yӟ12Ԟ֨hWay#st[*V: [.:*ӭV@m-v-Ba,gLc`I#Mhlڏ>񇢾ݧw'߽|q7w+2l~xnT{n7'vbI "z?5Ģ̗(4 {;(.XuH >=,, :WذNrͲ"ؖfi8IZ̄~)vzMKXcVʎŶȎQӰciZvgr["<&ooL|w?HO~H~_7 Iiؘ{ın_CaJvWe E  rgaY/ou>sG0|M|YSӆ'qOdBl6| فI<~p'R͟ON俞yOQM':`&ᓧ2q.%2pgxb%Y! ~ƴ`I!c[k,s}7Z(4}7(gD19ME>[+>bzƤl3G ֢0* q~F CGC0tb.pc1@M u& @)>&[6(!!lkǓMIوj o@; "Lـ,=Q98bg9 6y {ohK&Q0 zضIFbYMV"zXUe9+y}ZˏVr=xNlLM8;Hφ b|Z<7ƿ;)U M6h8&d*φu6 ̀=Ԯ==T!/v 8wW\8# h<`w*gmS)zw\(AhlT{ jYs9|zWJdo8 jpRΞ>`REkG/P4xFjШ7a Ҁ~b<-nxjy5Bw:%dJ.HJ=]Pr68'~zE2,RJkVی'!bwaI:+0EͷK «vKc/3^4tg!rzq:zwDMbVdqL|pjR'ͱ =x=z(3 "&1H9$9,މ-.^k XIp ^'^z'o|}mJn,79")X0ʖ~}Z(H}-wIL4`lxNs/&x&+d0xyFcN1zɻfs MvNA&d`cb?LU?gi8ƾUI ZS B% <턛P"o #@|@%w;`Qoeg@ lhf(: x+?r`*ub"(gHrE z%+لɞpѫCEY7f@+R]-łJS+PbJhKۊ\8ΣV~U2ىszi5@@SkN T? qA8P~#{bGћ,HaluS&iOꔧ1t򸮗gRГ? c6nH6wTRyC ߤ90 5- CNci|'}:͏bm9Qj ,د̿o9b=_wTSI?6][!o찳3Xfq DEhȗ~|dqmeA_O ;.O'܏_B0읇[ Ү9h8)jf*p ĽaK[*΢Pc` Z}fB.e,mgL=H 1Bnp26H B!\c<ղzʆV: Ndig 1p%S:!-3r,Awuh~/hWF9k >mZLZ `4hh& vyۋ$`Н9H)yL@c7A: UgFY?Hq&A9HEMTcl7gYI2('+4IHZVhJd1QNctD!]jGƍu@\" Ь8R+5Lܖ\Z3}\ r-hTq_ Sb_νkr~8@z 㔍|8)!K4ǷhFrp^~2,=>?bQbMƓl8ihuZkimvyܓJR`(SŷCg8 T@5ՓVeEeF(ʼnNvuX&6Bxp>I_@ׯI@ܖH%oJe\KDk{Ty/$Nx_זl|;"G#ESu̷RqbѸi.[z{֫yZ_FF2q d2DΘ*b nG~<gեt:1Fٖ%BJ3u棉o.{2euUjIaD͖#y.8?Eӛ%d i&{jHb pzn/1p=}Rio V Zgphל&Y @W\yٵ'ϋd6^oоZDہ!wPA5?x$~X~d'B(̌O2 %)GsIGxoOKZ$NG)SObۿL66p&>֓@ ͐6f2p˸4_"spvLCdEN+te^Q ͞>v=8|v(/Q~߲ GȦ1ʓ/3X"H[)G;;hlYD358e4[펿M~N&t7q8?y N^Tggŧ7KY-D*o9%#nUl(je ՠ-\uwF A-!l<Iӡ2Zx@UB^7ceқ+..UAxo 4깸7Ӓ͹沼Y Ys0lRI 磜USi!W_^N . c^`1 #P74[O%J>9|ZUqqԘ _& HzyZKF4&p@p;dN;&e8}*ȆJEQ\O)Y Es4;f}{buxS| Ar(ukȚ`JŘʧ]T Lv s51y}9! Q20ٖH~ #H}Wg4KN]Q-: ֨Iܩ3Ving Pyt⧰o/<>UFU`☸u :pkhxЉ,qm_r˝m_<~-Gn p?}N4@0Ynߠ[rĸ`L%ȧnA?m|R=3!OЪPr#q kC/+kte&+T/u]PfU+ NL ՎVlؿA_,R.Of;u-tm^\dO~nUs/ʋ 3"|jJ' %ϦqI @&a`Z_7p&-XخLb('xz# iw bJX&+37,7|r?1?0Yߎ;q)^<%-PbF K21%Մ&v %$Xn& ,z ^x>pnΝ> 9!z]4ć#>N\q}ھM8gTqayAjO ]e=IЋVJkP&m!C] ҋDסUWLPjpAc=d7J,d uFWc2B<TPR>@zzqL#/Y19\7ǟӘǴ0G:y'y/e*)CCS(\˻1@a3+-R:+(e<¥U ƧcDQ}]GJ-%EuvGM\Ht`DQy+vq,T̷K Xiy0Z%r~uR+յAe ֕WuLD[k ?#)XnFhƲ^*,.r>]YCkK䭹]WdUzz~8JM=aWKs ^-x:LȂKS m+dlOQc9ӭ9gXvjk >+gHAcT׿~xn50&v?L:s 2`+?gqf_i\]{)զMis{DZIm0F֗84J F됆@!ud`62Sjcq||3Yt߿_bKVe ]_0?c[ZPȹ*/2->\dB({~e0cX'i76q6Xn#IM5B H>45P!>w w<hNhno #z׿˫~X?!kjFDImfbgJ+am\l28g)uN3cIWkN &W'sPJ8Zh0yKT0/m[A<+1T`BH9` r;%I`ЎUK%(R(ܦ6$:p6UtIg d^j-&J~N/ǧ2awT拱^%q.<~XpWD4$-;=jVl#;pÍӮ^we75̶^&47o/IQm.m6.I$5un0tO=W l"X*7RHoVd /Q"SV^[~7pP>]PV%6Dg7XK#9qd-UTZTu+jnXk<[Vb2nH4 N׳NַWTE$ h)T-M>/YwXC5VKWA"74jp 24+E9w #KXC#j+V U *Fl&{7Ez,۔*[pU1(7&pV{()͐o+5@AVlܭv27i3֋5jsy@&p+U #,'4Z1kjTu[]7[OQ냽xԵ#ς UG#,=R®gݑoic=ޚ %^CQDhfܫF#lgnv!?Oa;Z`q]X"PV< ׊ec)C$}tM齼K=9dRឨteэ?=ׂ-z6a֪5\u!rpJdm 9,(+|7}exavJ^1.5v:Ć3ڲ)S*Z.Kvڑe-\M1cvEh{}9 %ʁ]*Tzƈ0cu@rBŦYgf蕆pfјܜ6QZ0+M-߫ hx<=&|r ~|sFxImZp}#? 6<:Qm x?ɷm~ O5/o9hՍ5۠@ P2":GHe S];fýyF[M}FH:j7%r3Ui3B P8%sր"dټ4)] H=<49}{qzGl,a9J`-ϫtXɵfr+W%V@g`"h_$;:}{1OwpLClr_9qe$qr㛓7z{(1.aWz- h.,R xUx;'aV&5Lhݬ(T'pXDx誽` 3>:G[|/Ё\#QHaaA7O㩫PFs.8bB $eMbHfQ .[s5f2J"+rp 3B 8{-+0^m :+D*P1Fb68 ǘA^>еBnM?ȫlt8*teRXS$w,,@>=dI!xӘ(0و^ Q|8hFuVW0Vy 4,5>n~|*yy5 5T +jz%3FN k$9.u%(Iѡ1C1]S9!""wC55p| ` `?5z*dX443{MiA/e*XlJ)c8(9-\ V4Y.yWXB8&Q9͒gmMMKl-SUga>zk4$"N鸭W6)|s U[bgE {IHW!+=mPN]tVg߳~lV6h imt=C 5mOS91ۤ_ ss[SNAt-⚝Y~v\lMdyz^^tNS&E 0۞GX<RsY\؟EnEn{}3QgP[ ۣPCa6RK-ArRxx_R a__&A 0K:^L-!\l՟!š^>⬾Upx8Ëwi::O ]]`藯 Ls痰u6|E~KD#=^\o_zWlZ&E|L6]dXgF#~E^ Kpq$m܃+D`&z9^)`C39PFLA:2!Wpxء-A)b9;Ϊ-0&}GٟGG|zth><3W'ST lΥy=J/@ ?75]|7D>ʧ+"T.nL"u-p 3qwKoct jq!-?k\dԇC.k4J!dtXİ:lN][uJWgv6 ̇ӬњYkCiz7[]Vk9CzB`{S2Abu{3;0A5w \G FkP<.?NDwP;P}hC`VyxC+-~٨.uwc"ľ}!^J6%bt ˚NxtЈ UAxnֆD~^,,[Pv"ߪs_;'N؟2./5Fr,C `f[^'QɗUd2'4y&JMvqɕl 9j_'lbc>SCadϥ$eBioVqf~4s*vT:f`)(86˻Ng9CQXV^EQZ]B ;vw7 &:?|re4S?\ iiju\VvQ#/!!C)SO;RG+Q*W<( gKfgZc/nд=zT=`/&N/OwM"(` (Ǐ:h~/XZ}&Gi1 B%N~P7o6wR`yEJTAow 5o#47N OnJ !\xD  L'ӫ:G1y&rD|{p rlOyڟavkZ< j`Mm1ZHxGpMc3bp*q VOh=TAG)x逫ZB.0Od'̈!.P쑗8.qQّ{emݝX]eVSg;GHZh 71$G`)9_\pH OM+ P C3q*E*@~ze0*nCQVB+@WhR~O A @=OH:A=Q.)B.a$O 똈k(@ &I][PT}G4٤~ɦWE]k-d6TCz<`cG xLt!(Q(]jP]嫇f^^אՆ;դM" yt|dCqM~Ά~g# A-^I^% D.D !F=6zm̿[ C5sD Tj#۫xQ2mcpH/3y;y:MٵnoXTR[C Rc]64 :Ll(vh6k48+_ .XӶ +A&G/KCvW69z3O[ T6Κɲsr ܌2Seb SW(<ڠR4ZY~u㕅L*JVi#C{lTUl+?U6B+CZb[UuN9<=O*_!k Ӯk޲jXôaoła11V0&ZmSV>O=xNJveŮ~zß $FvJ^kY 6%{҈DoI^"-_=i{ՅݞuJHw]Іn%WY$D=5F;zJCC[djL;7]` _sn~&КM`> ͓9aF9xOt9bpwtSqx_!wYh-/{v!c\4F`?1Afu nw]Qby$߉9;O-yC]>gy.F}%#xV^*MiMFtF`4ᎁ;L ɦ7,.Ș=fp ?sVJDXdrJZ*D.jiBzxkՆnx.`YU!Y°S5aċ8*ܥj: mxL!vey]`6,8uײdu-5<}&y"@F#7O/RF6xCeW@^t@׈ʂV%?BAtzfdB4w\Q[FԻfÉ !kn-d8JXSZm Gb ~K9)cř⯘Ӭ87}A?rEϋ9XˬSD ?-iýhȥ4-__@ ~3=v,] $ ?#1| OoBA\ HO8QE=cBtxZNJƁͭ"vTj7^4?yV*Amȍ-U[лBr+B,uPnEa|;tҢ11_;=DȲ/˻P'Xї: "O y } Ustd6T4 JdE8o]A2Cd\K|$M(QQ«`NL@GiVf LSb[|`$y8s^d#gBZs^mƍ;,6 9̙.78hkV7`sD@{{Q#Cdr߇9RRF#lMg1% C!3O/HKzZBA8>5R5$T@069ٔE6ׂ¼pG _t;^urQs9HTf#iĭo$ M=MC9(pT3ユim`v ^j5enCw,-;(##WWNnc H B$dt's8~6O&y~w/ClXO5KVⵜ=,0zRjMBfLjEw0/7ězt<D%٩: f+l.-GN)O>Pzow^ۭm}!B|ZbŘߣI3 iFR1v(HgS\Lv՜nWjI~I^EZ9L91(vY`u}n<-WJkTjp.Q+(ȻQǷ#5;z|aܽ+Brg{Fdֻ׷#PbMd%X^(;(xc)HxT~~bIVI_-W+YkW7^t}x_ګb.@֢.MGA+x"xcPOdWҁ4BQPKIE(FJO=Ax{t|Na鏢)5_u!BJsy;S/x_W}@jRA+翊)9hYMICU6ecGׄ\S>؄MZ< ZxmcUR] ԎA`>P""Q#+ñKg=]wOByZWn.rU˺c z]zV nĺe5a# Y*HkWQ_au38yj(Us|Fs'HctWT|jp%y |2wqG"'3_a.ߘt<%`>tMq۸hQb8}=j}]~u o%ȝhW_nkh"~6vv\Qlw2SAۭ^i{fѿlR_̲%N4חac{Tޟf x\ޝ*e{{.PM\qRV LσEisXBOf&f:R^CTec./*q ǀ Bsh!M(]i\猏7)}w|<8)hH$?Z!~madLxPymnZ?B[l$P̂*Oؿh΅{}Dϡu'l=k?z<3raҫ (6 <UͶQNu5;U4j/GGtw>9?]6(V8)0+_{|~>xw{yxfmL4EןDy cM& @0T$~ $0U@6rGkVAD=LS P2෉Q?jg!Qux}Mu\xΝNr*]-J]-]9L/hOElhþ/An YvOkuLyLnnV ql?4~݊UnD*6G y%h^dJќǠf:~̧%*-}u#DŅ[k ]*%N /1;R>QŴS5ν7ŞWhX@mš@ x_>#FkL0`5p bL>u1BS6Wۓ vG~#u8W0N P{uWvJ0X'b4FV*i)Eӣ86e-D!ǾI7o\6Ly%t L_:ټo1dd\ȩl iI.Xx߃C /ي|y,-aѕ 4d~9xsQx^QMi4 b ݴӦ5s׭F_A]`p8otV/bӧ7'~6': URPo-C3aa/$_[ RsG9|Qwج -^ml"w {u%c]ܧKw_+^F^|rw{HKS']S+7_{]EboP (~W“W<.'`J>]G\U3,Ӣ/lC̘l/L%QIzW Q`l̃(X<}RV [C 'sP ³l󴚿Ǻۀ2@ =$#YkeYU pWQ+&Sw&[Iٛμ&T<űIR|ƃj9MMh6^BT'a 0?=+@c?BXD5ߒJg*w^a|XjN[Ag<_\-&S^i.!%* lyI\ !p:H'P(ʟƲ* хh0+jݑ/AZsl7UݿF?c w5d!Fr\il ("%,;K&#㷦mNrVlIgX6Ëwi:/CSDo4G`Pc<'0.o Am:f iMjkGFh]|Q  Y-!~!F:VsBh YB#ߘnE1qnjUzrQWSc=#*Rx5U٘*?azF Gk C:su=ȅPؿqd!Pg!6b2 _+e<?biJJU,>.NBiN (fkwIC 5b;t[=*+SQ gdY^Lg%~.h1MF?n#M aʉ KbijNS9=_/3S [Fm{P#Ոcr<_:g/FޕD+~WTذGl<޿@8~JG&U7WUIBCkhvuB7( Ch*_ӹQ99ˑA4VKF +ִ"\)#AGl8R]js__Q-]>Ba ZURdYH8&]Ln H+O5Hcűa("ϡnxνirw͏Ax( Zw~F%a鈸 AQP,.:"-8g[ŗ/n +@{5h/'t27s,n .+,W 7vpZDf9qӗT׏lNv)pԀtqN "&`?<|ڎRBoܰ9up*ng-+U~;m8e3(M4C~Š;_U2>364C\7r }Y˻Ssv8Q N+#Ypov>93]`IA|ߦy Q\"c}ݤ4c\yɞD7gEr6i߲1P,e~DQlUL84rfO1POwJ\<f6~4j2t@ExR 3r%TRLTg+#͒٨xl~n( p͑KbҨ9|F`Fm򯵷e.`knea}Cֻ~!;L4RD,'? ]4OF=c GRI1ȏ#d@5̅51+% q葕s>)fiӭ/T  2@֗aݍހᮋEЪ< G{8~G`jɆLOtk5,{;wTH=eq6m6*^~>_CTȃ͎/ƕm;Hq,_wu$o_.yjbțtPc<(߬>Kw ;2p7 {l:=*Og|)gfFD5T뢽Md1ЛDg`5%B+E`"K= 36fK<ҷe 겴$#t!;TUJ2g)~:@'lV!Yav< :'H^LpQ͆@zL܍߉7wp8*l0N i\1xv qn'CMK,w!D!)T9#9n=89:k=|wrKsXk%O7CR 5vhRHc o AOIjBk۷jU܀Ʈ) }Mu'[@>*X2i_f<n@i Q[p(O!i\DVD1IJdaj "֟ob$o=[d1I['?wrtptC'MZi*\;D24Yݟ?Y_3w^_?9yTc-M>,.I-[/rqqDIuC9sb#v('nϩ^{ޞOQUsB/‽Ӵ&˹>Q>h[h$3UvXd?I_s[b& <`)t1嬶HYt}Vf7/㹨B}*ظNȳcXW)TX:mb%ǗWq}XcV Dn1uj,q+}XTah@#,vޢyraV_17T y ^3Y``RlAԓx5UQFG[_rpb?9$Cso+Uip+A|.Yx$u疬oKpXi8dp- *V'0O jFLK qAuP!\(׵o(#8 i4Z}kJ'?'?(;lLyDЮu@ӚpV=ԓ<%dk8xdG6x1c.iKXi;QO[FBYtW89Z8/#n6e5oלnKȨ#B78wb { lY"jj<~fR %3e>{4(ZRLHdp݅G{?hݫo1k b퀪*]d6~vM(|w˩5ϝ;SQLMʉJZ /FA?88[~|-?gֻezǽ11Λ="УYYqYSxƴտnj}`X`!_ %Gk-)ӛcNt5FN))&Ylz\LL .+ިPXD [:4f'vx&LeP *׽f+O9˘8| # NFV[ߪ:-nk eVv ҼT3^^"+X V{7oi 9ML- OGDL/ś/ $3#Qas㛓&o|I (ýYy w KRk+xfKg@bK3OXW 6VC>'|c ^d\QVƄU" F'0f͉EPSCSZ 7Jw{ȱ`Mfa|NtSdu }8X>=}!{Ey؞==aLJ/[jq-3icdH,ȩgu :-4]qpN }lw!ӄ?Qѽno%\]?=tLZ}zJPϘ^*h>}Z5ډ:DxNkpdVy$o~ h>,|OzsxOx?Uz@RZ_#yT/uG.z4\w~*dzMz*U*bZ㿼_Em3{Z0?~,h> g بkN/?۪_ۘs/QUϚ\-"ٞ^B9}r:ܙ׷#Eޘ)(jݿۇG|OPWkXM;ynO/CK|T=P=F*{x}{ds<ڏI~ S$q6:]*xL6?GU!o !~+K}i_X1:v7z$N~ӓwLWBуx,l|Ȼ?}x:.~m?? 5Sͽ]g R/kag|=~;߃=J](O$_~~ok<~5O\,q\3NP?\uvocdwc1߄s?h5/O~.jNΌEl.~/2{How]ui<~fz 婁yS_s\=i?jrgR|>l_:~i7ߞ]oυ`}/~`޳uUq7g!SW}1˓;G O^3a)x_A=#K~\K(WϲWzL2wi_~eSg|ǗZow^}o~>/5叿[ךV։y)-m~6';Ug}POjw <^*/oo $K_C,}u?>aKh/^^_T|Ul-X?Yv/ 7E~R}HG黨ObCiXy >dw  7kh 鏢e&*~^/ /ߙaCM Z?IaT??`e]`.1`u~wO<(1˛߄Po[ʿg|>| W/HU#\/z6^m}s~J;Hyw ?!TIE˧ӧ-?VӏeVo6XorWmu_xa+=zjM^SCw7B7<;7 ~<֣wr?}J4M?'Rd=4&ל6eKt%RN }^ZAς݌)Rb?=H=sxc&IymTu/[a޸_ݯeynN)c`ߔ%jd_Gqϲ#Jc$oyz/@̅M6|+wb<|9y駤xۿN`'nUMo! D?i;9K./_WQ*^xu.?ϸQ$>bz'c(|_>~_u#riB7oYdt h='/=:$B"W>=|7&w?wЃ?Ѣ~ʹ=xߝ&)< /_kIHuAMB<6_Ǹ|>&u6×|K'!S/YK>;OaiX1+jn%G'^R4։PzN>)7]8?~G7.?}rDK:߿Lڛ3wm@Iփguwg?/{NWny/tޝ/Wo#5}JөW<]%x<׿ Pѽz>{xi)LiB_]~I9Yǜ=?:$ߙw\^{*w:zzӺ6_d0{=71 /Eߓ 1ēϯowL!OP>O˷f2wS!ASʋdzڥ!1KeO>*?1JKCmOC>NOgZz~Lc '7ϧM/Y1/0w/E^2>9!) h_^b0}m͟yTx\xLcnǑxBV>6|_jO)oVTȗ܍_ཹZu9ں(}%,ǤoqB T|g#|f:Az~{b{ k׭?YAsF:} pq s$úW}y~OEQ;_?x߽~>x̃dI/Ѥ:MꞬcUׯOZ}^V|kO!#u{1G_H+P-x.>]j<?Lս׷?|gO_G-[O/o9'|n^>Rq/S>߾<3;o~]Lu _pyЧ .CBZuHTm}[-?$cN%cZwc/zE<}~~, ۇw+Qܿqn鴞V󊟞>RXVB.ɟrOY^z+6~| 7۪eM=ZHsWm%ܵ/} G+ڹcJׯozrۯ^_(7VBo-ޏ5/򒇭/O>Bc\d8[)W޽6?W*}"/As7o.13C q_.D`S%L`0AO=RX/cg_\?m^}T9:^'F11z+ %)v :;]͵̔<]o3!Wp]۽͇0 5 >8$>8xAX)Z|A,./;:] "DP&q^SWMĝ3ڇDм/Rn3DItb]U>n `@uTASe6n1Tw !ھZfNQ꾬fHwa{Ot6*-"u#|coS,v]2BTPG"*po˲@t :B%&۞ڤXX2,t`Aw={T}i{6Ϩؕ0[Bjfib(|1$@ i˸Q;V_p`dZ.xpL&;ua%~B>ix&w *~\uV i7 ˦&9N2Mk ~}0|}sdƌBח2JwwJ0Il*C׉m88dgX5;f֛'tiE^]۹eXn[8"=_cލڽHhJ}iq\ˉuha8fD|̪7`%*W!;OX 0\f;m$N 6}㳟H>=0sWxeyQ5+$H{\;OmJa \hVURc܎tMZ;=)WئS 6z ö9V3Q' (Ub܃9:-;\DVݘn/Ŝ]nDY_H[N#긳7\gT)dPջ PrZӛzj]̒/F/mP.{7qlu 0ڱ1jh#ͳ=B\3mMK.^-] |aڹ\ezDqVq,W֛op58Ja@G :RuS,iX ^Ǧ)SB^źf P]}!ҰdK]c:S9yI;KD*B&Zzܪ2;۾]i>X`%@F 3QV.a.mԜk R% ր#[>R.y7y@-[G7a ` a.XR swUeYd 2[.d|Iĸ<Y\e_Cx$J]|t B1oK KQ6vUC8[64-@]ZZ>3B!md%2ϗC"aH@&g U6.ĶJx_*פn%h~'Jabsdw9,v%oA-ԄV^$hr!aax&ot mWZ.(J8Ⱦ-]jqZ O.T!ؠ 655߰\Me0u'&X- Bg2KOzyv S)Xz\<쵲¨=ѳ*dśzhՌMd>U>~s=nj> ͤPbW^Ca$xn %2KyhfIt:A$+ 6KAtAj]Fm5@Wr  -O_ASGĉljLxԔtsr:$lfRc19b _}a̙<ު*E$1H_wrv^k{gDuۡF7LE dJ4ʳ6Zۍr8fЇs.N*ٖ%pgr64+ӐzF {h94SvHGvA7ylj'n9p+) "АUB\iQE8qrHtGz8W:6TtFs8 <p5W5eB< {64b AZjy]T5W,K[39Qu ž6MNH!hKj#gJޱt޺a.CfD>snD_nn=]rulXǐ܍$W]PAB:+v~:;~+6YQbCx*,,vU7 [-4jdȚi;+&//s LF}8zZE?Y%DlȯBjnǸ\ ` -GތN_Q%NyK;‘xP cau L PAe$Le%G(0xEȅk;ݰk2̜e;X߀4nز4*.dO" P+C@KbPjL I<DZ1OgG3AsWR#!̀CRw %ߑg|iTJCG H٦s}EG#7ۅu/dWH4V-$)6r9<9)xAKL']"nOv+)@r 2ni\}opū;wQ|0tP<`ҡhI %MDOG6.[XISց:u2{rŜ*'ZV.W FGF.cY/*r 38rՑ ZEZwѸRL+Y44~g\-jRY- } =I+-S4,Zt҆4u@ųJrv:bt fZ;<-rilbod.&̊+rźrz$REuNɲ9U{,]w *$Έ ڰ`Z5~]kտ2(H Tg}ۨ:Rg蠸R2ӒiJ΀"ܺ:رͥuhUqmWvKw;-QZ~~X5`nd2a5hڵ?Hq!OFn âgeDs;8}3 to.f+ȩ d8;M cܬmz"%N$R;ͅ#v@M˸ ,:UmU.iOa'?M_R!NQp}=6 koQCR͠gN|hzoEߺ"E9L V =usxE5@{ Zh!Wb *,;Gckd+ M#CpA=Lb6N xB=~YLܛ:ȭvz.dë%1X,X .ќ.Y!* pu[r;X ucl2Z"_y30ٕFY+#[NY.dhZ* YǒYjd%DQ\nўPȗن$ U?B3!] ?ЉܳMgط]zFFYR''9/ jBJ7"eY>I*!dNoXau¶QG*QlŔhS"sA'ܜX[8HNΨ>}ܜ 6Z6 l Ӵ롫kлm\iwvM=mcZvۡ"(yUK 8r`\emY2A k(c/ 7 w/pKBҁZUr26z˔5w.YJ@qN&% `B7ɡSUIA0n[/։ Y/b'+E>U8.`L'wEEyC}xwIo\4ȡs nœ?tPl'zɫ} I(LiD x,!g+P60tɏtz#Gpg>ѺƪRaR-[)W`]+2v+!!Utklu;m0IXљ$(s'.vD}0Yl{nux3Eks(CGX3wTJW(\.vk 3e&fZ] qժSO!uN wHoX<^5p_rLruDJBa'F^(V4t R$A'%ʆ \W-t'TCf>@*#Ru>'\[zJ!Y\-tW iUoTE<2 '-Audl؎Os嬜#bڊXx7fP*A^>t VTL`Bf6" чXGa+ EtU O:1qnQQNaU;@*4dlg&(SZM4)Jo@)t[4{ 21 S Z񝔊ܮy!ӛt'ޠscrWѫ'^# -KƖ Cw-50:DYp;/m{W,$AH\t ȑe-љxdꭻٵ%$.Mh#YK+.]4ܲ[c|GydaAaw la5Bzr)xy YnIx^٤}c,V/`IaG!Q*ڱ0 jG#C_TeE:8 „n"+ꢽFl{9T:vaW 7vzd`/Uy ,xS 5ي%[%RS!YWY~TEYQ4!W$tKS0oj8:Ȃ+;hAUF;7Q'n^ {b:fsv[{ 6UG8pCp+&Ũ5yTs+HrA# DxV»+~FG3Y7A6᱁7xVplh E#vӭ, 0] ;cYQ+)K}n6|8+0} -^1[oS7xޱCkۊ2JIɈYtfvqz@1GGXjf U2P*>6>ڀ 䙢 t;C@V5SXWp@S=5I^}y/!-8.mIܠ]xgۜxAt<\J&LHrN3q*-J "ECr[lqzT7k a)eڡWjQ iHSUC,%* n>T_6G97n “r*8)i?qYnjɾ⺽VAIx"p΅ɦ+txycz4we'F8V8mn!_zj&hA1v u{?H[j9R[*'>]uc65;~C |mg*鉂[W+v>:)55 [hN-l>Ps=>G^Aut<0 e+ABC},ELSV6F䢉zLh }joZ!PVԧA=82lT 5뫰hژhAQb-C iQ)tsh *8E [v>91ay*. ꕣ#u9V'9$";mMcjzKZh$Ќ A3!!IPcLsxnqs\yESKh!bؽg3qiN,ҕ}PR7:b iUFw`Yz4:+?GٽE"\zldp`pE!ah]WqFr֪t`[~1KifI _XY @*ƙ|tQ`n<1I!Ukh+E@%3$IF6!50+?U{aPZKUlbcGvA;vr_; ke%UgJhD9I˭C|Oc$"~|"&>4*j'*d5NNg-Z73 @oxޑ9\o, 2T@V|H`ֈN2kW%?B<݉yb+| 3_bAЙnqDlB4CzBKhL9k,'ZޝLEoڄ ;+"hJCR*Eש9 _Fe=[TuSw[ܓS۪13plZqf7gL/bai[S^ih.M]dK8{y+'$CŠ?V3X =* b}0nܧ@<-'Vh5"Brqq-ۥ^̤ H2Z\_@4w!hZ5xiLA7(RvwM<2:@!IVJZWxBZVcʒbxKqO^pM'Zuy#K^IwabzRM˳FYu`kٞGT͟fq`J݄͂'os)I_𗤍rU߸ͭ*lH eҽs_S6Vx 8f :lLW2I@.д 3N |qVDp^WeZ=) pq-krnڻ U)%kX2#T/}܎믪ےag@ CoWj jU=(*/>Z 4RR +P1!Bmn~Gl$98)vf+Fa@{2^g͋pw U< Fyws@Nv뷡psViqYŮSjl(zl 3TDQ~o])F6 dohv}\REKn&=P'IAɰ[-InczՉ9žI;\{nyYuQBQbn7!L^WA*Yd7Ef e8 o2xr$PEQV٧=Ypީz8{+Wt m*#!)cHp`ѷŌGuY{7vVŃħе8ٍNCzQt$=ުSw=@4bռ¼қؙخm۠&V6f(ʪLy<V~ 8lAN;6#4{ުQƖad+J1sAZz֤,|1 0G_{4Gvn:]Y{p;(Vla,i!Ij(~bD.u7}sY=B)Wr A֋{$J{Ůhqc*&%gS$UNe ɎaJFv:$u; .Ht3fd +X b"L7nAVDJ iլ!Pfܷ>;m%D^B"mNq튚k=mk%1≎`. 6ZF`ò%#xd+( J"m䂄Sge{S۸_0ޝIo` VyURS A9cԢ .v8: g+?3͔uvҵ7W1]c'y+;Dp7U[( мWfAjMf^̓zJ[գ!±tO;vͧӤv 0gmcQGs)pD !6W=A(\ŕ'QFFb|C1F#hVg"ZFu02R5PuS5YR|R`ޖo=DcBvg}79~a>?X9Nԯ` [¥"7cQaS@A65vQH/*Ui%{hL?4n`=+;fo4G;="P_YWOa -3\ֿuGr֞kc)uh R:^^'o[:Ѳr- *)Q#Fk:KSqAl]roAFө+{wo?Pؐaϥ=!jN h NXt{48 qkgoV.)%dWxp7^ҿ땖EОh2U+ E,%8!螀=h3q@V$N_ Slr~mY .Kfd() 8;Hp8=?-|2d&,L6s8c ՛:&Uu w'@QD#*sƁ(S.vd>ni0VMF:5䊨OV4'rEWRʝ12JpeS@m<+\v@uHTj=v+_!.%LR+~iYؘ9WRUЬt344Gs(I Ӫ]ʛRN>"t5G!_jiJp۝m WK]@N4> #B7yUl/o'T_Kc8+L0 5p Ѝic^ Q sB6LEyG:pD9!Fl󓾱R4xƙ!E5;ߢӠ]K Hw`gv| юS'LhcFD$!/c +Z3ԝ+pj\Pk72v/i` ΰOop6Yh[K-( /X٣Zl%$ܙa}=cmL68S%DcR O*Dp FWQ!`(ZkA +3_-$,ydbf: i]]$H7󂝖q" _[3tm|OM"j~H\qΒe81Bs[hʭ,Ǡq~ڍ*؟a.j֋B<ݕ-KiCi"z!ͬ2#Dě؏{ iS6^ɻ h{7QNNyfVcq#UsMR?E1>m/ 8T|0qYO 9,Cl?.TyTfnj!*pbPU0ESz󿖩n|Z;9/ֹ3wodXǓ~ ֋/]te$[T,tJh7Ħt> Kފ:O^ȞD"O N )/4 f\OZ#Xv)0O2C9˂cu泊Y۩K|~fyqvL|9Uavҭ to˩thK @.t9s3۠+)F.*-1-${z"䓚V[›ء'b0;"v곿 z7?J8 l`>Ikk4uF"ŗe):fc 1[yű ;N'Q (X9':4p 7[J7.ps# 4 88@,eS޴QYot0&>Xchr | 6n( +f}-dϭ'q鲆ax B!=~1*n96gȊehq7vJNl{R)ҫaT3G't\!<;Q&َ4!Umn{(H Jn/W+0:oݪe#z%<%f=JzEdU ş`YW C#9&>Fv? Y1YCUO^4qPA &JAV aQx Twf=MXӺlSHvzI@VkL5h?$lB-4'bn<@#R,?2.;{-#?Û9jz@!ppxp5LBC1|gF^sX..t_S0^dW^lbM-z@ 01jbo5k.o7⥅sҋ0:*5VRc:d#Z|V.Va/?<@=Zdo$[厗Rviyr4A}@/_#r`c0^єĿ?1:w)xUepƓd5Dn`~\Yf%kfA+MS~_4xΤF]ޞ!A5xӗٗ34.D.+;'4}hB`)fKZkԊ0D#7xK5C}bR屑ZIv;a[?|iai QsbWnQו1 z]@z[$-HO"r U`٤5F-&6N6ۡծ^z UlQ9 d1L XZҖ` %ѯ>@5' _ !l<&t?}1ŽԚùؖ&<OovF0j6Ćq,Ӽ{;3ګx ߧGizDAArɵŊjvկ'l6FXpnVY[ bUXsِb}$d,%G()7Ɠtp)k~ !$z@N0tF:Md]<-,uoMeެ}cj.#%zKǚSzb/fNeZb1U'עEA2#_8x1l,`p:/;)bߌ}1JSptC\, wqŔ6τB nJ3*_jV4mOJ{ް.gnma8<Б~ fVނK @ᴷ(1[LMz$.[e6wLXHAAd(=`}b kΟfES% mr`ݩ`T9[2{|v+w!%sܱLC{G؉l )Ϋg 3DlAPU.Ubb ]rِgh, " m^>}:8 rc_2at#q;oNFِ}7}4M׾"ng ӺQinQzMd@Nn= yD c[Ke41]'(6J᷼*IDI vD~Xa֒utT)=.NU~e8_ҹ2DD7'Us6'ba:gG4!./wѬه?Ӎ'`Fv> `|]hrig 9Uީ&>؟+LլWnBY݀JÛUK P,+#1t˜ a tQyY@pُf<7 1v%w,6jMdGQ,>E>D#{niu:735ъpfńm"Y^G$'mZ$" !wK;J>ͺRI.RH 2e ghK ]LK¦ˏNKs{ky%xգ-$</Kӏp F5oaӂxKlF"mG_·zY 8\K1?=<(կ yzx }{O. Х7ӟ\_kmoA!S1 ́*S$wk+7R ʿGr!~  ]n\edo! Q?Fgy,y7l4`500Jˆ:A_7JTݕ- )‘5g? Fr[=~?Ln)fUez~t}gz&:g(yg"rǚlov3~6{yV/9eqZPyzoU~+~rk754{NIh4c}+Y `cMf؎ncjXiAV{Ũ4SV͏.sHn~I7n`%;eqG|x~>cq9q>C@iS -} Ƚ/t_ /dѦu?*~Xu2WoycմM^.#C>2GWG"W: /YRNaבi8^&Qʌ}͋"^*#9}4a@a*xl[/@Ȝ|~yDs |F\}"/FwJ2L].|xWsj0^Xnfwc $c;L4G4 m[B??WyYtKF[ؾCʨlwzP&)9t]k?l=[û۪TkbwE/1t[ hCf1E,V)Jz\SMIs ~ yuGkä f[d~vTo[K?HD0ꋝ :'W6JStN.g ~f-Y?brqt\RX/sw 1K]K  ;ߴGLf7%;O,\YG^+@!(yư]+6:]I{ݖ(Ѹ,"I톱\(F3p1vF g,?zgS# 5E(jߟZzP5βȪO>Ӽ˖" |,l!>g$'qȒAw3zca"Y1/"tۧtpmŰLdX4ccqc~'c6~.Z%6·y^,CNBJ[mvYCC6PPj_'tDEn$9XHN;Hx6v^sNU./*WEOю47߶D$6}u}TZ*٥eGs.&2vw"gL]b1wA<AzRHBj-Qw_=\ر_0S)XBTy:P-&Kx a6<󁡺l1ѥ 0v<#|F~zH~';%t Us$:BdE}K2WP8df.;~cپY33Cu%"ʖe6xۮhGN${SQ !m{T-K_R}K39T;M<$Kic,s!~ڮJi,|6 v{*+wgYoJ &Q_wAnkSH5dO7Cb"ReYe^Mo 6äkP%ibqϹVA BVCTCugU`K~X y1 z0엞N9t ~G.qlfKN21]7EAf{8ˢ>wAz|6P{@gh ɤy ftUU"~h^!s9_p5iWQ4goK '?ó&m)2+36 /G>$>!EZ# RR.nggOr b*jtM{ V1/.HhBxK炒L[+ KL%'BdmH`#r\?bX}(2ŗ-LLe5+?Tш=n8Hv닁RL:LI)eQ0x9vkiΖ]Qq:LĪڨp@e-V#UҜ.ٖ(a[!SUHj 'ͫꭿtzڹM1KmD|E#VadY$7wuYZ18{0zޜJ;xk?`O/ѱUr l{_TsrX?cihedŵ/>a.'3 7C +2'7*΋%68jPKQ-=/gԧ:y0܈uGMs)V`fFZAmFWt6g;i^2+,}Ql4b3M3K/ Sod0Eel [OMkx~~\ѷicmGՙS0$M~x0R/#>qc3䢺g=f)rV} ,Z<ύłP60őWCuGܓR[##Se qSmOS$\}U_^֛miof&G6aWۍgeN g.4#}t_ԽqVh[ 9bq\8i; WhwI׽7E.4^wSFo8^{㛖!QB5@B2@Ϳ}DWPM}a%o3Hdl7Ż%+8^78%^sW&Q~ჽA1@=D,oɺꨪsکR9+{H- +xOP#T8K\7V-uư_Tt꧳,]c "u}n4s TM'ۗ+so-~h9ڬ# ~ ftY'n:қmsz.%w或V7>*n nIqĚ/0Iؤ!_:fE}Xr%F~CDT%ȼy$2/~,e8!Hû/L s 4@Kg CND.9[3 ,5zHM.lH:$sK=4 ڷ5e.H$1 j4 s KA.lr@W i}V`͐4G730Ѥ*xͅzŅ _1z|i% 9[Ǒ֩N[3f蘬,bZ"%42fޖ8hԃa8"&*:w&nƂW3XOKh3OPDUmاMW6 EΒ>:2#E/C.PH4$ !BqwtN,/y.0X:h]-&cF9'G>l[~]Sðc{1Na]$S_QhXn401k]12Í/>/*1|Nu^"o4!f 9_5lh6#cK`Wګ6xk;tz9sEZ !P7@9e4,&=`"=%4!FiR\:8/!S,+2<@/fpLAZ a 49?Hl0rx/GR)"14̺RclRh{K`b}70:;zNʼn)N nRޠd@FR"ӧ}»_XEUE8SlS@H7 CS&Wb.؇Rնggx)}\5RHJ AWك8{ƵH؄9bD/%<]V & Õ|e9!.΍w!/|yt;5w"Շ=ka.9koT& t`4JCX,!LD^ \bcwý֏FQe'9fL3ߪ>&7Dz$oOQ;QgBOrӌr߈CŲIゴc/u}Ͼd 4Ak^LXrvd}u!2KqAOл$\Ag1Z= ™[ҭcw?R.vED (=FQf`G3uĖbMfxIt(Hs\ Ԁ+ǧksT o綛<髝gw? 9^dJHSnf}nԼu8Tp3ہC&c2%nNJ>8!\@nY~$Ֆ4(u&ZhB+aML u`B eBz~vXBm;"wyYq$#-i@臺^1_5#ch+wM Yc=T]$Q @D QR:Wss&rssRo9gBx|qȥ0# ~$_Ci j7*A" ·y\r% ubw]?3orܔxp ~q/06A~ș!kPa=ilz[qZ_ a)M\WTn#KVQDOdB&e$y+s/4LvnbQ'5uz];4W'a⾪ Tȝv8s@^( M79LY"蔑k=^>X|՟@"tJ$>S َ Rf(~qcIR8d!;-f? pVCH׻]V)RH%^5&*)o'i\Y{ ^JR+;ƛmăr1RϬFN}8lՅz'=|c$Sk418)AIUmnB0C(u﮼iP?zՌ n5^6{CRb-O!E`yl$ ؇F!]uRFǸB59/.AyN3Y8/ IhTeC9N5I vgüQdʅ0GDߺM:v,`Z|Аo~/ s  nZ .|Y4%rY g8SVb% yXgqU87}NatraKdй(b%2 :#789P~LBmy3z]!%ML搛OndC`EvqE( Pz'bFdcG~A-v>KSRPa|G)Hq^Ib9I)xQx_0 |FJ P!!]Z aVP~S7E<Y"ҐS0ڶm&" - F@Ve.bOG1y=-6[9ɧ]ӯf/Ob{3mB-߭Q jݺ\V88YXa~1=@v]Q`|ShoG}0~m䣟Y#*E#?:#$U[j*hFew8́5v1j0D|3Ͱ^X0?DU9fTiY5mL+ysBx5nv^N Co!}w0sH<~t`0Q:q"喘@6Hߔ握(?:)A3@X…3 U=Y9+v*~`sёhT\Pۿ&d7ꅺt))Di,ހ‚F$ xw8_巬Y 72Ms}-h:.+BwLyrFx UDyr7Lx*sb9 RSD{Ȏ“I 6d 2aSVsF^ xDB=f҆wh\;1Ҷo½} :bh\ 3O%> +JoMԎQ!D KL lt0"ԅK`Yɾm<`jT|ɜ9a9AP既}4\Pt5@lj]-Bp{9MhyH|NtQ=Z&)~zp(> $ZSV} v9mt#_ϑHQIM1o.1g_ FTyXl3&HP"Eb= 1BDW&1el,Ͽ^aeuy jE~":~[=_Nt{rhi7#yA z4qVV/ڳ5y3e: i)@*0s~^.7(u~RzHpTOϑ{/89>Bf>%Z|By9LxiG,,԰ጴYus_?ժ72M9gfFjD<Q(>h<9PuAŷ[O#j3%}|3(DpmL} adrd5.R}.y'(ÕJʽm{BB rSda߳.#X+FD%VXvc~8xAt C׹ f+Û<,~ _T.iPR/I| 3C#9F)0|eV 2'e uTXNNd|]Ĭ(˕!&nƎŽ LJG(8T>[_4ƄIg|o.Ŀ $EjTpC}c7 43!ШI1CVqH"4`t-vm:XFӛPeDZ/{in~'#>$q oh"pXW'fOxq=wz {[o"_;kw*I % tEA/;4} p~;^eN#&>l(39juE<"u8}H9Wh8ĞB(L]MD!csm|K  G(pʹWŗwɗ$a u~6,Ɇ)$N) a); 6c>BPS@GH^ߦ/Rr;XmAb MMc 1!e弞:d[QO$O6x>P;jLFrhoN\& ȲiZ޶xD?vJ ӥ7^xG< <0 Y*SWl-!GwdC,~u`h Pۍ)L@9}F냢S[4Y1 @TP?R<.6U>z~.#z#_N3!ꫀ|> &( Hi85I=qֿYhMsֆ\As7W18ō.Llce0 Nr' 9 t¸gߎ~ݏV& ixNОeXB#YZG5y E#$X Ƴ@ ОQ iT@/IM 3TH3+LRy7|I(iTy'5]e>$;!B%!Qވ/?I:ݑِRY;iHߗF}zd􊂢U{YQvڢg#nu2Sn/? R QqUhMp~8 G 7-x4|:q75i:0RjCAeINUH&Sr81`R %أ}ۮ&fju6IpM]| ? ;Tӗ?rqC* 8v:`?J7a2ˎf L>p2oC_A ,UG\^Uɿ/G&BC)m82'$`;?J!#tI^)ÔqIэFtYQ~#Q` ѝ)]iLv֩^_Fk_a+\۲3ĂOq%۬lOa=Mɴe'RE[Lsx0JJu`g%n/Z5lR)8*)f?m}\UOx=((˥r CjR.yVU+9$3;'lzɢ@#%24_G7ɳ͕z0 &ܫs ΠDcYPAJyC &ȀZ=ҵ^\ &kD 1Xw}8WL˵5vl EbS]x-FYY k-juVX]6<٤GU9؆(q^J#Vw%Wi S:xI/~>W 5ũ6 S^6rR=QL7Wx3H>!qd{6Gx0'  *q7l0L]/̜>~eܺ9 ٵfRLꌜ1RGTٞTCC%p {݀cTu ľ `!>9oWsGr'm#2;(yc?OL fl/k4 B΅uQR XloXʓlrq\KsK)KbHrl&Hw.KHMWC麄_yekA)} <"WsYYN}UOMa؝;ICO# BH-v-Is`G;1U7iEo 3T)NN۷k*CA˅.!+: ti:PK0[8y y:MN~`HN5~;>h77rK-ܾjx69IEnOigBx2_MSx객Lhl@^ ?l':+ҦN.|_.- b1->Ѓq ꛘ`6`ayĖ2K67bC('OP `b{ c;XtrT8bW\PZ'}˔F6D??G攠7d# hP-H}|櫸ہI &J Fm/>}J$ ;CIjya wB.3F]T4wgޔ'̖L iǔFh% W EREMcCy{M.LtJCxH^rObhSꂐǰq*(C&$;A6J-A-p8|o,u:q5*?)FI)+c%NqM!?"9̈́"aBy(3DʃIz/?Ww׻(dnnnBXlW&t__$SG Ba.I22o˱ P Js|;FtcvhVJ'gAnSx;_5{ MȠ$*"dYK_ Fɜ;1CG Jh9|L/|vc/Si?A!o:Ɔ=\-;M%xAU,h@ڱo䏌cz.mIqGl/ ɺ9ΤBeXUU;|Ve&Y0' Bk`x7vƢM1O[7 ovj#5֯a bբO̾I~:?̮BN4}LyVz} JV#X-Y]jz=0*eTE_CA^ ]{ƗG95H]'Rbѽ#_v8!v)ݟ1: LʷlYS*+ Ɍb+ߔ|`$6VSBTwk(֗8eV\ -̃gmҕ0A[ouAr48OvTԕ'Z+nW >>_WAƲR bR0F;f5/^ׄ]/x\cWThO_硣~z"֭>X˾r%fM)E{ASnjgz|TvZP/1T+_j[gh^\IaEoFϤ\50)`"җÌCMv (={0X3Atrϣׇ+s w~ A慠@spzwLrr&_L2XްL}'H $ң`m{&+s(4Ix"k PB)h)1|*3o_j^1m(HSyCЛ4j ;B}=QMSi鮐u` hG8.4 d8ʠ{݄T()ŞvkyN]Rd1mGV咸.yEe;&nPM,/;rGm|@*NΦ,;YD%ӱOtysr;-OwGt>8xT'P]D _unxOl9eK;(T0ψe4X𢢛ٞE@7>x1I-԰oCGu~T~6DfYg.p6B ~Y^a<]0$Z>I:ƹfSu^(Q>))8VuxF& ~YU-('@ug?M1s}mk#ИaδZΚ9gμrS'ei%%?^0wX8QX|xh$%F༿ĩrƿ:a+>*PTU$_^(/ft&p& r"& sTӳ'd sehߐb)nx"))cQPlS |.UW-YR)BRራWӏو)բây8&։kԁ"?.{T>G0>A0 qW1ZOMfS> Ug.dF=r V3 P.bf|Fv2k`^7%`mkLS'R5OlA ڷW^ɁC^346d-K)J`4/wv)WO.ɴ/;~ ofmbXo?4w )]`ASb<*#⢛ʅ Lh<Ȧ2sO6s|ހ$aE]޵,Bco>C,]Dk==J%`C[W0OqG!6"ŵ3CD71O/~gcSwנ $Yu+.'9868Zky&Њj&(N*|Sӓ* )MyCk&G/oï?*_$!۞m1 @*HU"ᄕH'^6`EJ#T.́R]iĄ = bBLyma]U.!)OAʷ-v]f4PJ:h~A$AFZ`4oiqSdTVU1qi+X{3F$mRGN W~nq \9T(WeeI~E'<23{|+e=q0刍YH"hiHħڥ(&*ӜȲ|JG?<51V"6WxcM IOO=.`,$kpZA$(ɡ lѮFZ|"Šhyt0k@O1^4i}UUfþI0#< Z@:)bat^0xϋ&jZ3~"7ˆ+&5e+ R?zb N E7x<' ?,a(b.س`VV`&M%9w*Ts͏ ~: V4c"|- >[?LkkJנӗjov ޾́uf Q;U<(+fT 66z=Ȯ3aџv)Qu2| :dh9͋SƟF1| '|Rbh^ od\)IeoSslO S ,Ȳm94c{'?f*FU&IAH{w)yPjtͿ.M9yY]OʳHoʚx;*zӓՕ[g㕷lލmh5(?߯ԀmK~.T ]+Y3OMPWZ03CY]c%dQcKV(J(nc”{s 釉O HX'{M ȟA^8}k*'ᯃ5tva xgÈwzǴ4Ւv٨]Pl^v18ǟwW>^..'|cNm ׇyԌ[?@S:#/k&$hk { 1-8)CC BY:JVCH(#MAb7gc8A%KK2ׯ<ӆ,4NR h`@xaFeDDOf@O_W3Mp].XϾ!ga 苣tT\W8 ZZiK栗-OrYnr@"Q F] &29IjkՌ( 9D7AZѯ4ee/́B IlD4رz@&]j`#w~MK/r0Våݣm{i^1: \VL]X<'æE;xP7`WϮq3}M:-'J2={q&t:U#m+89 UH(MS6G s.آ*%F^;A Ta."P2vbh(Qdϒx{#crʋcJaB5 M,VaX%Ӣ$d7:o/SU$ fw~~}UËp闽ihw#zq?ٛ4mYst|*=/Z~||IߋO~Y_? \ ւG߿7֣dޫO^=:˓/?'˗d}ӋGj>i/^O^n'^6 ~=]M.&×brRan'OGOOv{{'6/ڟOk˓IooozbowU8:z/O>ÿ?|;x?y}hϗka<G?]hv;oǣr}_|\g6GϏON^Go?8l^]>ø7|?ŏh1~q_Lc8/ƗgoߝLjGAug{ocœAh^?t<18_{jkZxz|?~.oo|xy6;?jwd#&?nzQ7|mm'~O?7_7ߟًQO滳/lqX8v~q||_>n>6ώwj˃//{[O_|yj<=2x>_^JOW:dy>._Bl͟˟zOG{g{㵷˻N~}u{}fsUqp3> W{ݷ.۵ntEWG/.L/ɏOنIoo#>k?ՓO/LN\z}a|VqDAۏGE՝]y>=xszyUwP_v>I{g'?nLF3l?q/ǫ^}n}o''_Xߟ|~mc)ŗgkӏLJ˟/ }\ysy{y]z'z{a$n7zi 8{R}'\q:LVJxi.-hpԍ&q4,eq5xY$,yǓaoFr/u3eUW]/j4=Ā{OpxwǁN\Oä32X8}8N:o:qDt}&<@۵4AHZ3т)+v0ы3ۇ<`e_KIcz| esL^d-nUʮ\f̥-7Zu )_s PxYfEM3{ eVYCR Sc8]'ջZ;DL|4P*E\ʊzta:fb8v7>I* ^G4y[,.(1˥͐U38Vw2I &jj/uݤz'c^ ,h@[$LIѻĩ*˺J2e4\qU^.XUI_ߥ[y =c*%8V>RL4R嫦›z,FLXZxHRc%Ir^1 Æ\QE: 3檰A]Ki_NU9:g9r6N\ťͿ;צNV{_7YSg^ketfn5t͙mYgZngg~7z"KJN +QwXXaX'bf$[)L̊򿼂a4//|x|7;;#n=JiIm4]XZf]4/ mH"!brmI$)dI&U V)c yrqy?s$bXXlr|J.fK$ȥDJr=)B'\b̤)zbu1%w߀@'HƕaLc0{Qc&B`_$/0-1Hot $ۃFʰ6+Y0S'ا AnÒX~cYFHNUoMT|l] ܲ 2EYĵ!⢳}n7u*joM\%iσI7S=R3xGu(b~N\g~3QJ]KjQL[ꚱ|+f˴No5ܞIBEMdLtf:Rw0v2T"KE#x Q`[ d6CY^#Z\9JE>WZ :+DDz2%ltY|Vj=g cpW9u+Z6G-0 i%; ~ B-?96@$3U0b<0. 'GA\r9&0>w2LT1C*]c2Nv B6fds2Wm*z0h Zi)YbqA"t邔G*YU@ASז=6pѧ:!ߪڜ\eSGa2W,Pvӎ~9Z+dzqeL1w"1.䀠pz[:uA]PWۑRi8֖nRKd8CY-äʔ!Q|: |^d d ҂)(T=bpn_ 7p ag#7U:Т*vb>ZʞHAkkgD61AU鐻PbʗƸ\i7!1<7sG[b0N$7r=L*N1Fjq.X\e )@_#[ ,1ʌqqY`VMxFw8Q9j2qwSO ƨ XKOYRR%PD{=5MM^H%oWx3hr'{^b50t .$+ )ׄ3i1Tŭu8o1XnAfmo|] gރ"5)$BLTFBh3;I 敜bBi0{j?{JkYnr")1)#}ə~$3*%+V +kY6$_cbfsބN|} fi҃8{(eU>! %ߴٲUqLtڄGLZZS[׳ >0 X'mOg ?MДjJ]y!N $3T$t܁n L2ЙauKnE!7%L#<;Z r0eIժrh"`-y*'/ H,ˢՓ;aX.c#*}S>DBHIžJ$\ϪLfx aE?tn`)05[(Ȥx9QN :"Ԡf<-8b4 | d#QydN j1e<L}d+)N;/DͨШ}۽];]F$RT"%aFCRVKEZCQ<+iDyk qs^\Uĺ i.C Q}^Rj>5 qfK5{RȰ5Le$;΃2 ~=L]ԘZ} _kNLx ySd'Jx(h),qQGS~ӋvĿ~lu>ޯ<~VS*۷I{nɪk\X*ERY-mn(UȀA2J"^]}Ǜx[䕅N3GUů^Ez8Ɛt]QGP %sE`H1`Z=,Pe<-OaH]\?)sōܝAH |TX}T`~x!:_ ~q礦ΦC/}:`؞EfUW tir]HX%yRȈQpZ҆eR5?W9!0aV-nHUsUb`ZG+_[6^ﰚ(qT)71:0$9ګd4INfa8قVsAgecmcû'޻So}csl~hW{[o+/j'lov6u`\m|7wxiq]1ikXRPB#yݮ'OU%nx+g~ k_:/yk^2KkǒʜX5LqGԸEO$B+*=.+G<:+1u MGXD\kŊ.Ɩ,&/CRagL>)8aUfP1>luIP}ك OVH+tCWZe<Ua#ʤN(#z5qkԑy2AvVm3 }45l XaSc\V ƢOQaV;)j^3f*fzR=ct@UJZ_Z@蒡: )&;CqoC7-_oYp.qQT3W"5vKFY~Ha |}xUUg02gwz2J^!dND(Z Ld,*׾G t+{E[zTjt֡T"U33J!~NWZQEfľWч^On_]w9+goo_^VkhĴe)eS|"i$]} hO*q` b?4|_R4w6R='Zx]ʌe/ /x1j0U:"0\DZ1IW"5{Ho7Y'MM\ wuϊ-asVR8إA;S-r@u7⺃2PA-x虉1|n7w9Wb*)C<71+P^G%Id 8gIPb tHA؈.[G `76@l51Q?ZmH ~  8J lGg>9U3E2e/}x@Y4/pq*IZ3vl!ITbuk$a4^8iS5Zov0}$37V8 8Ehōob ÆDu^4{&K.IG04<ܟ+F4 C O\y[P5~&ne.?{)f29u{ ? N?p=EeR'P.qY66{5!٬ R%MU%Q{WIi/7SG@c-D]4)2Bg ~-]Ve+N]l2hz+RĒpQB77)&^]glqƯ=x ?^Sd%U~!b'+ 8?o9iny8b1>uP~`Nr@)Hiك緭q;w.S,/|K|}\E)27Tlq=yG 3]rnM\*u]Iwf}&<`n2b*= ƆxҶA!du//Gv͇.꬞wm:q@eˊ/SB]ou3.UWݾdCc2O[s6XshV/p2OՑ~skv +D\;o2F!^gg]\{^sXk;;:1 }Be͵P7O 5 ]fVv#imtZ+ ^~Tt؜VdC\X=BG0 c<:lL!8'3*c& v͊:eWRp)Z/ƾ|į_7mm0n">V-:g=n5+<L4<%vPh)}/H* ;lp]5 eBUuNǘ.Qr+#9*LMXXo<m 7Wx&SNN8$zg*X/F ) 6K[ЂZ 5y:Z FQ 3/÷D [V˭'3̡S hekRlrM݁Kp j >4J["8N\- m0V*Ou43%.p3N\RSOi>էpzD Ş?֞]50$WķU 8 xmV ?ҡ?PA5~qbI#,IK~L*lM>wVP0E 9˘2(cD^,ͳ"[qY,-G %JY{c$%LR@Yvϒ(WáRϥwUH6o9GULO:2?_3DbL ?{ƥK2;䂥.Vqew[(\y[Zqr9(Ϙ8d q(J'󭓟Tu*@lg' qEΥاu@Ȏ.x,Abeť\<&E6 QyDTΔo~|1f443Kmb A J0xfia=LhTv;0/-Bf>'^b2mq,8*䪀I V ;@Lٺi5Vxj5 cQeH홂V &?-Fk{m1h~a4+)-kFk&0ۋ =c<,g,.;+I;d+ ALꁯlz*}M\tG01m`.#޵B["!'ɷW|‰MYt7.C30iM-swKE_f!6hi%:9߷4b*jm'*@4Nl: 2B"JCs=w݇]wt4WWs Dȳ<{W?Ck!/ߍD f3W$dֵ`0.</f]Ui8Cܲs9Jڷ٨FT۰i*ul`!GP&0NٹΚiZ`=3$Z  5ܟyzN~]kƾVh1u { h9映^&yb(V&QNVbu")P X- 0 #ZJ؋8yTŸzI֏(-0ɘMz E^skm fi48Q$L\3=IOC掳ӤJ'm-s%oCD\.ǏQ_lr3 2Twwxwjʀ}aL'}z>iV CA5M]7v *ԍUG R:Ux77y{ov3U>Yh8`>6b;S?{!I{)#OƉ!g&lߚ< OI˙WitB|{H̿UwՂabA7٧d,ox9%Bp%hwX--_[҈}# ȏ\UqF8GEHQ۱iŝ Hr^ G2w>P!dC44imXcf~@qT?JQM"j.[zS%qݒ),~uRFq؃;A!eUH]..0w\!c +7\*LR4SSgbl2wS Z YʵVXgZ'1k̇j@Oa2yW>3Mil9a_4;" E+JIrYRꟛepԯΥ1|O/.ga&^ĘRݿOXHp)?azS܄ʉ2?(zҭ7M]MuӱxCR<'_7qPe OѮTӉ_e7sW~{뷛 ]LHekUr)D&k_)zƍws3 R4o \<_;FRGMX\4̝qmUy((haCr o@' l򳃥Z6Qb7Mv|| 0eǂ!B+\Ez>_vmW 0@ѴA4zp[kR? ZT OeX 'Ա\U&hxNZyJwTʞ]^XnoQIVǭשA-r6B܏ G"?ٻBt "7ft~=RvDU8H0~zx2,"+B"orTqi,P:K3:[a_?f!V-uWNGL堒Qs oc}HbG6X C=~P:yT[1o;҂lv\]B0HxsjGkh xhV9ꁗ95II+ d5**ؚIT7eCۑ1ҒJpa!q-GgKDRzI>)I2 :I?(i)LI)[32#hI״w+I;\1HXZ{D`a?~7¸ϿQ6Z(v&({IER\Q!K쿣d=$k-wJ@T[@ՠS*y)s<"yȓQu3׆"eIɌ5*:{G0z n0-u4f^^:L?2«hFj,/^xc;6YO8$_lNYf* QPl-3iK#vٲqQ`]7 T=$iTZE[Qjֈ=(OZ"bh6׸fsjl6y_5(^9E~I^%ִt}̭VҏA w hPs. da{lnnvV7vQS^)J9GTqtƉtlش$/?RXR|~ӗ*1`sonuTP"$+ƒѦޯPvi ItJQ'|78a%>He- Nl*@ X: Us0Xjw$aUIexm\~fsS)*h 9 6|<=U[~FAǍg"3RǓ:.AgQi5N񻴳YzȔ`&9r$ƯBI!Us :Y}ҕެ e1@Y.}vRlBej`cbt'yW`76UJ< 4P{}LU^{q}CrX "ũВ *)txW&-Cx8qa5#g ~VI?UL{lʠeu\B-;N5oՏ>_ v}d4N8b%N|}\%ex-wF0]A9!Iķ 8"یx|HSنL\)yoia2s}( w>߯_awZ\>Y3+׸<'Nc [i cn)9:cwxWuqn4K:_YتYБ iumbMD2C@aI *$-;,S"cKY:=BŖ\<0ZA6+y7k#FHhBpMf.e蕃ݟ#,dgW[!sIvB8Yk_P1Kg6b>,E1Jj>r1ڒntI8oDڐo1PG$E,tD4NP';<:xB}z7'V qfkR3==URSUʕL@Ukt D"0iWp|-=밀!0 "җ Ehp" Sk+% J*n#yp}\Y+&Bw4 ZPvo"ΊL^me˖%>@=pg8 sS@`rg3$:3) 6tt;#䦘]Z~J=a(c6ŕkMm 5_|yVSq*gkjc_"TUw |9pz+vA, Uŗ7O;XxN+Il"48-w|MDt1ԼSW G0@˨-`tڅO|_Nu+xJ)yS J[ gԻT.40KSQ|&: ϵcT)1IUTEh']^$iXׯt<扡0x-.ZasiALUT)."# )S'My;(񚃑@OH)^clE>;Hibq0ٟ=se7Z뎛0ʽ_۴yDjհ֡h*z1 ?ˮ! +XQf~Y(ڍR[]cOA6EH*@8H"A/d0f_:ˬ8牚8+*~(bd{`R+X`]lP\ $vA04a{F9KD37xͲvLD`~?+~%?׍WY35ͩ}\}yh#hvZy+yr%t{W7)3A>S= %p9R#H9UgO3a'Ob1b ] ARH6Q@iO=bldz*9Fش0pfDn۾}XZ)<'CiL">3>@&q64WRǷfYiJDN%o/yh:J"LӃ+umEN oP׸BWU0J}UNW鰟 Aat7)d72:ɋ[tw"x sQZ ^#"L9y3p-ܬAS5Ƞ&BiiJP0&\-D;H.pcQl/D|fTU;KST>T` oWdV7w?/: 3\h8?&K%%a쎔QS[$Pp|Bqs@*m n(L9wK020AԡkT9/DDJPO[[[·_*Y|(kG\SQI*Te)_%NϺ^0z JZd\gwoL"68X(X7P=y,DfAHw͐)-XLOwn%(-(C_phZ550u%y[*>Q nTa5"կ)<"OUJ ͎.J(n:3AbIyٞ T"_<ȽEf.:.(vbm+~hLP P^)ީ9E7vz%h"e8>]&Yu GxMKt }%~ӱICSAWQU eP9m%nK!NK*)b),qfZIH-"![ Uy!"c jk>[ct ,bĠ_nZ+:$M5Jά8)Ж?]8c]зqVzx#+vV(crʵHV*hI3H t{*#egc}S]ƱxpR6}|ô*W,F%U<*$z%XN'].l޿_PT9}Q뿑(࿧IF'4+Vm :y\3yjB use0dp^z^/rE=[3+P]t&ZU#=pqR)S[c,|X[v[5ZG T%wG!*bxa^Nư'\kWL딋٨ڐ4c)w:*x\vtM`QIbڒ~,I*.B=E:d %v,'rH}Y얽fģi81˥7thPzWnÃhSAw"&s*E;c;uas=fN; :Oދ0^' j0|8a􅭍ߊ7xm؂DE$_4#Tҙn9§k3e*=[zһsSN|pPKd! *5Gޚ5pqoXqAC{J7A0#`sp$sD*C@qO!!^&_z>An?+N9N`)녹M&6cX(ՎdJ9@$yJ|*`$0a%Y7q2KK6Pi~]{\L旡gr05@N8.-Xj$xv9|d3%|cny[[Ml *UoWlygJz}%v⇱t *#٫jb=Z r3gPfǻrm51k0g6e\fD={4z!Q6SZAlPɬJXBCG9B3l^tЏAiP8BPyU=wlB/KF]\pk HU)Õ 2)_(P_Ku@Xfaa3Nt(rW[ŏmOh7~CT%֐\ rqϺzGR[@D 30O\7/vDbMLv#ŢRSQq0LBrꍯ9Dsw |jv6ך>˥0UIaDr0]0w5}IXl[bu ̱D3Ͳ~yR Hk;% &Il:PqHi&ItA/f9CmCm,te:3JuM[8ĥ+c}T71xþ2h"rJO}=2FFi 8\Z޿j_\nD~*"2l-_p;& \7$S|e;myݖx²Uy<t~cBDԫ|>"% 7 q'ZaD׃0 !26b3{Xh.fu%2re d4*Q\pd5MJL ?Z8GH"wP%?#rҳfG['x}"Us,qI"HY!fpOߣ؆n2ض'GS% \Pd msTchzNXhVL#_rTGAѮ,3a:.Cڕ5 K4Hq`vRfbPu{ ca |/c4wRl|>[Ao'!p-D,`nzX<yzFD2>*ke9L4rԐ)ѸapY?~=0/Pѐ";;",(sZfqrދ9ڿE%ʧSJϫ[k6CCẹ!=x@P=[FG,OH&#+Y\ 04\(Lm1zԗM%W*}:e%CՔ^$ӥaBM9]e䴥hDAf4&$[rʚbjCƋ\fMX߲6oX*M[NFaAkڤz"HMB3irLeVwio,RR؝aݠڤdhԾYdjuH*.Z`IB7+En#cPdԸʛ3bp ^AhܠK7UFFϔ L`{.;W8* ö.dpIUܡLz* &{7ZMI qݎ={+Up՘AUV?Mhz])8[XplǙMwXz F8tz;"EgVCYtnKк*,dXQƬpFa -˺eZPx^ݠ1Y(6"p0,R\œ#j$Ltjzbd,?wjmy*Z`f|fYn ybcm0u@7GH^`e޲JA''ADQ%}rX7MO<7D2ǃUu*bhߙ[39`NSs#a+g'h 8/]ƧL 7*XL87:O_6hrz4 gH9nMz}5zQRvjbL )J" i; b!}0<ʧeID_Nz91ȉ#'ٛdR]&e2Ȯ$:qE vv8O7\1\q٭Xэ$cwP7-ǿMPVF[ ul֩<9('Q3FDEi]}g±?,h0@Jl9 =]Il^MDҞw+z10K uꨒPd!~кh;38aHW&[n~QV' \;! /J{>| ++nv fͱ=)X5ܡH$ZY0;$EeρB30P^Sf<#>܍gh|Y'Þx'/czL3ٗKT209]iV")aWɄ 5VJ%#Qƹ5T{KPXG#i ˠAxp4#:=r$cu.Ѩ*x啥e|Ҍ ]$Ѓꘀ|e`qu&_Kh3)4opU8!DK/5+mdJE e'odNѴĻ6#F̶u.>B_"$Ty"7t%oU'9s-WU~KtU&43PvQ 1߮X1m7 2sb B86knDCz3\A*$:HAGo(0î*43o@ k\jJ7LTM›l30nQo&M|-]VZP*be4Rs8~f\7yA&Ɯ{< HOY{buk ݇{fެyS@qbӼ>'/Ǽhv?fɰ^p\iL D }ϥx=:bp0hS338⫵Hʇ]0q2@{voek .ޣ%Z=}w77^Z(vmF m;cg o3!I b ūݿxjl>&%~ng5nJĻs/[p(b:|^,j˨Q>a:ZCFuuסI(Eٸ 7lu+ɞ"}ʼn ='AΟu|Oݷ+v 5OI7ڇGR #YIpX֙bNr*}ӡG"[1=y (W%,J3@񌓂Kne"#Q|K9b2{NpƚIZyB"l)]o !y5TcGԳQq/{TZR{KyA56 ^ 93ț:#r&4P' R-.j򮧩5>*a&l Q~a0pa<[IYq7fC:IGmu> i ,r}pޗt!RY4\\o2Z x氼u4Tt\Cj9K}4}YO ]Z;{VR; C 4 5@v}# [騗ׄÉPf`w4>Ud>gh Mkx{v*C*YSxXڳŠ'g_0^뤂LiS 2Ûy@ƓWK&6 j ]gz'|EnDxt̵&%uu^j mY3HNAtɓy,;rقQS5͟s<8鸍c8ӈפ*+-vsdTM4*礄04_K& UQ8;bmak'Uocc{\l67w;{fnLgЙBWK=;p$Ri1&`[L'1t#m@:A4뜆0"G!7_AC8DびTUz˔S8Ƙ[@ k+p[UJQ/!B4kmr'+nfaWq2xlԘ WtԌJQe_í 49˵)c_t;> ;j5ĻS׮*?9!3߸R*4/[ZRHcx1jU#O<,7h 9ҏ'cR uhد`slMLzB73-//=#G-[vhonb<[l&*b&z'mn>녗AegU_ V Ҍ[|n!_\ Nj&uT%o )ip1*3qɑ-*d*ЕT?V;fk6\_.֚[nж;]3ǵl&Ԗj{􋴶4!  v7σ;pws{8PzPo?2;e6Bֵ\Wɋ: 鯦-_k_Pǒ_Kؿ+u&'5]\~K&AuOPQlU9mR=BCJ7t4BɨGXCC1tÈnmhC%8La)cnaӀꗐ 3&&k2AZ'hgs{ 踴IoKL:7mjNaMՃR7+5x(W~ *h#ktȧSFA{rPWbk4_VSutB;Ɑw85ЍMw,5@duٛUʍ뉽gmx'|@M1udu4O'} Ncwws{RѺ{qTي̪^gEf-S5-Nwb#TrSy^Cn[S@nƌl&c۸)ydԢ$'C GkLJV{~-%bsI[օMRG2;.BP(Dik m3<4{nFHwma=4?5ľQ{cmۛ^><{p=r(sUNT?̥%9&*?r5]d [o:{2Քlr9gP)  4{XyM+CǴf4<"Чb.6(#t(77N$vA?<[< ":ռj_l<,QM(eBhj& 5v7<?N+\`*}}zZ瓫@rM=OYUd#l AGA%r㼚y={hͽ,# ⮖ d{9VUex0*㵼~~VDB!&c{CU3 նƬXjBݥ{ ׵; Bo w/y\(#Ȓ]] .8)'Y|H(OBh~ :Sv(7(;a'sڃ[.6TYiܰe0;؀ ~KqDNQI[OlXUBۦ%K$?+F\f4VjGZִ"HՊ HT5ll ]+5omkQؖMG9 ~A`dv  x囎A8~R#ZMpj# d'Ӷ: Pyď s/ {t#3=}r_ "ڱGcޓ1% '|zyL21і9zһn/>t/eu~#v> p,d?HVڰzk7ۯ,κz8f|p35JqPt h:YEQKoޝxdaoV orJ 59c|;WT*(.h̫]z⺝t558Mm cnUM}5/p#;鉵ekSVeso@]M^y6äv,+O 9;Gm~Q]ˢrߢ^\l*/y[o͇ut#w5'-gLUz/ƋGn{#b*H[n{3/*\ǧݳͧR%Q2s_|L?ͧo%Yex|7n'x$}[uAJ{0>,AFӋ#PDNVQ_h$h#boc Oj\:R=z7'^oU%jN2ϕHt9R؉\Rej.Yϸ77%x0l K@Xl' Ѫ 4 HfrLYo.Sڧ>X$oq FO:ii*נM233mOUOmR{i2)x8?%MdUtt6]D'k ^F^[Y =íWS%N.њ iGNp{=v4ŊJ!c[o'-nV`U_{t+ֺ60d ZuLGݲb{UŽd)`0m"rhu6{/pPw{CdDE j (VףFԢPG۵LsK߄Ӏf.v i1wXYgL%xvN.3+MRTM_E8-dd>ϝi<'|$ {/x3+9LVeR`8f]-$hМƷJ"G+j&\FLZ:}3Eݒܔ!~ײ}, T ԣ_[:ñي֡Ձƞy;hYr6wu]oR\0%2--U6knad ,uW("Lq` b%c6t&<ٞ5u}{-mP+֥ws /M=,+#O_z\%fvČsXHNĩ?CCl9~],OJJxvcYՑmgnT+)6r8;Igs&|npB3=ޜ,TQI5q&/*c^pt(WYb8rc?X_ lNs'Y`5n)u]/9UNyU>Z.,Gz\F>׭}2䜶= e'C7U>qwh?#nBB=(YgFn#Tm%HM *Y ]u۠꺍r?'eO7b-E7c!pqbJm3M`-sʦrʎDeag\_MWo$mBVWP{ƷjAQg,|8O'UQWY^X!"xs /R)`OV.HhS|D-K6qfV36Rdnpő - wO4xϻ$~D3)1WX.=p܏N}0R'=ȴ`ܞ3Z#?XkLl8~z{?5((&ꏽ y{՗íM^~ {c3>zCHfa$[P[r(Fݛ4ytY_7'LxqDH4^8im@AqZ6cM:#t6ga{owo{|oxC[;*c SdhMHʍb^Q>//9(`Vx_LҖkYD4Hi=\~I.~IZ[2{|pFG+;?WYOud^[x2ީ*k׭WZ[/^&~-Zz`џ1IvMTbUok UjB%-uPZD8tGs11;J.,hyܳ܆!oN<\olk\冘CvVLo^ѵ(eЯv]cQ+r[N{r<riT {^ӄN<qRGEg[C:g)zSU ˯m!|!<ՕupBW\CW{~h#MV7أjmuuJֲ^͇mjJAe_v׾݈*ጴ}{fwP\ej/Q`v..Aӫ-ʛwvh呛;KBZQm, \tZTg>tծBVqwSi[w@)6FI=1vn@cs"A eC+]b9̶1a8WKFKD*(v~McbӪb]n6y8M@~ӷuRO-o>R.s'jj@}oeczEfuƖAY;vCܻ?u 4qz1ضG\X>읿$J0Bp Sq{-fqU98rN/Ѓ:Ayܻə߃rʳa܏l^$9 w 0[}8KNi~xQltlVa+>܃|"lޚ2uɷj{m/߷չu;7x:Ax[)GcnEKqޞk:cb<̡uz$ݿ s0!As8c Ycix`΂zvWd0x&V,h麙\?v;TA}ZVD8]@' w=އ̑j w! .7~=]r+kOp܉14CuJ=ՠȆg.oÂ1;wր5MmSݺ|}X~<;MCxcOnbi~L6hR 盱vk&ˇ0O00x~mM䣲 TǾe(w![eY,Gߋo{]`hv6-9>A2qdns[kV5ygp/gR#vGލ>LK:ZG'Ǽ#O)׉٣~ քPs0jun0a$jCGjif=jEVQō//߲/Eޢuͅb_?QER-Kw&.9z+E fsT\d66tnSN}5 )xȠ+VqJ5с& sskc1]pp,F5zٽ _^{u8sf%4B;ֽ4l:ˠQKk}3l$Tj ٣\aU~- ,N O+Q*g*խקXO:;F6$þsS-|-P};5H>/v_QgLk8 p_u(".DR6tVWEԐo}֡5{^?)g>m5^_֜'Oܵ<8=|lu:`ҹ$(ᶒԴ* !f`}Tp#/ם)VΌ\#&"w'V:>JMmO Z`)qO:zUREMuD',T .zGWז]o#r;{K)>J"$A$Qm!54C9Zdw.xqߝ,)I[`mpNaZՐ,L< ^CYPFNrOfo"nn&mʛYzݟ<]/ՃYq hbwlב,-AV-w>,[2وM\Ud``#Wh^0{m*kgi1ߚf_"ݜ8#H Gf֡30uAD[旱?w7"韔,@bOS`ۯ8k#ulxV>'[l@O'476A_Si#quaCI ]x\_6]]GaHo7)%t7팳Aց\B(^|ί%  ౹)qTj _A~-0mD L#eC1 ]kFH ÆnDFHmR=id6l@JƍeCe=wjVS:3 'i*T4΃S xSf켘FU6N|y6KN%e~bHnVF=rЭC!T@'WHnY%Me4bGq(:OIoy#494/c۷ҡbrB B\aavjygЧQv2LoD*'Y@B5ńa\ݖy#:a1_$]?/d9X#$wf 5㙜Ġˏ-uj7*1WP]@[HCA8b Doyk8r&ߎM؛Ȩo/p9H R[w哓ZQ~&`bY Z9XMp%Q]mfy^9W sBy1  U?R@M莚ZFrTh@" taSP;],ѫԄϹP8@R|6l1" yDo~SA:]] |X ;[[Йsf: >qK)u(7^n>wFnwrJQ -QN[(d91g2ET+jڜ9mطm968̥&Tv٪Rc;pW+O% wrG*?vIaV.>P,?9;] vh4ka 5K7fXmXt'?dN zkKfj{jNRy:o) m}6`َaQs+=\trZKBˍJxV||p /QB/7R{ԉ}YؚC_Kƕr\u6g ~E Wȕu%դM=ZFQ?^m 5g>xrj7XXjIbmwkj^UG9X/ 9Y՚Zku~#s<ԤCh <ȫBx}$v߬J-ꃭ1w6~Oewwpޥμw<7k͆}Tr7XfFyܭߠ^/j:G:jYزԛK6\\RSsx)mT4,k_ j|!( (~yyo@cPGs" wt4plR9Xp_/Mq8AM /m[@ƌ~SK醎h+`V?: 9*>0z'澸K(W~ޟ0^1,&8^1MCkn!*Z壪hY)X>Տ4=>yRn~ZQOݼ;!$8SR 4N2QěGRR9)'㚮 m<[&"p^}V/m2OڇE:6voIw"n7K& bF'@K7FP#dX*v.) B/D>JdGpX0eځu0W&_Bt5wnE.wyVg]ؚܸX {|L?ףՐiRTFb޾*A*8nn6@:ui0ۀ͌oȏQd E=aV]HS2a7קr<9'fFjcom.SSbđT mZmU;wqąQ,-$tqr/::pMWDOsO [dv\܀M_A/|wU#$[n\RAzUgYIHkDp}LLdeqKnCۯK+hw`% s8Gx1{fUeW>!|h,KD<#gd \wZS<9grhNLëӑ$GeQd,C &ܱ2ᾰlwD#O-ĥR\oBדּGylAZ̠foʡ&\1^L|i4_P @KOAQ=ΰ4.s0UXht{L@ǜy"byĮ8=BtB-酈xjtb6/s~A#:Zɹӫ9Y֣^kt7j'Mg"5#~F@S=gx2< F-dU {0*c컯5@.DPl7iWVMτ߶e1 o*Y({l|zj֠A6PN݆I0q섷}mPanx,cQUsd &6j;~U^un;P~E3.WՂDi)-FLG?磽lˌVIP9Y-k99ϫrTNU)ǂa^`Pɾ>Y֌gQXIxU@T\(AqxoOu):P]7?(,Uyyzn{1H*?u7EU D;#Fܭn4HONA4Ŋ3yThs'~\\X LADE7$W{AdIiA5 1;>OFںMmmZ&,{ǜc92d|,l.l A0Uy{4Ѯ,K09{1PpIrت >IY]>r{Wߺ>QPR/e@G߰a)s\?id}EqӳDφ2<]o&$M= &$j h-%:UzӇП)bSQVcalJ(n"tk#U bհ# x&I)Ms\A[5/bl0xqìF4HMy0t|G+!8]ڳýポQ.yy@A4 ƫ!X2{Y}~aM˝pjmGܱ yi /c*9\Ci809U+6Ыy\[tf/}1ǯOK&ȌXe|vfLMкShWz s=IC95Qcpv±`b;DYfۡ~Y8 6Iesx8 ^gw^OZmz3MXwcYo;f.& *C HzLǏVzsrvjxT |W?Xh\Jc;6ѭ S06Fд>r ND\bjמыj $'w^fm4SS%4Cs Rʲt#~Z+DjXiԖxs& vXBv />jӝ^lC}^Ab>8я2~J贬{6pIn'@WVH7)_Ek:Wne22 2ٱΰ@3GPXVƷ!@]!M79=y+v_Ȍ}FI=EDM\iymd|kXV9뤖dUC,glrI1ydր"GjF~d dIp:z)npvt- ϛi+r< }>\BCg+/7>߂A|,Fe^XNp īٕ\;ˬCҿ?z/*׽`^>d=8GI>mߊ9Re$;3cmH I[pzTxM ^E')`zXޤ;}{ykdg(rIw4 %rIo l~Njh+SQm8(?\sU'S\ٝ>A4h&iy:*&Sa,( ;dyu֒AQZd]wo7ÐiDɊ.H^&-ye: ׈3PKv!KMS'8(G39 S$aQ?_ܤ[Z8Xc^Oۜ ltLd_8=* 1oŀW[X>bǨP*`},GV1ᭆu i4Zޠ}`ElikbH.uirů.ީ+_D%BWH,v/*Ug#tQOE<^=S<N_Rz~Yt |&;0.*D1ӻ}uXQ r<#6| {W02\€¨%7y#3{Zs(wBYyGm!I71P/$mgCIA~nǽs+ŻHwnMVId`~)zֳd.'8~yj): H17 Q6 [Ϋ2D-yVCjz`D+9-X$hl%u?Ide/+5ZOL{n+ٰᙥt_i|SH/قEB[<a }Ff [fDmE->~D/*z93bH˪wS}7$mC1[juZVtΕ%S5$zai[]h##$gh0up SvseY 3rl/J?`Z~-ImR97YC{8r1qh3u{"8g5|(qf V'OWi&3B3 ^=+>: x;9u67f?s/͆&.gU(me^fIˣ5I/ #vJB]0Jd} vj{;ٝԃ ]f`%#*.PVH[ĉ `ij9C,=N8Oٰ'OppOTF ]{vGY*zk0jWdTf^v[<ë`B'V%N3m`n$`%lG=)&BR$gKhKo7'N:l ױg$63Jjb6f3 z*A8xw+w Jr6 MEJ] aEf܇.>gCY83w"cYިYj^j/=IԓF\H]zjcȢ5 QSՉv'ܽڶ1̭ÍzGzL0Nj@I^Ian$ļI ž` W#aN 2ry+ŕ=;<~e龒`DL4R70N#8Ϗ^.>ɾdETOp2sp#%5r`ɣ0t';|IR5@e0bQ|U?Vt0e VT*@ 8/12 ;dXf@:/ 'Yϯ}hw֖R7F, 1u{Y|gZSpym`8D4hO쨀`֎sӱ]eg1KU}+bD?O:*z Kވy]8Zwu T:# tcܡWoϫgL:O$EqzaqX6@})N>P-WOoTb;=#FAyn9!ec,j=ݳ= R0EӜ'c;Ψuij}f|Gn8-wM޹#y }x9rb jR@I+V^v^wf΄+xL{dQ+'|\ЛiؼYmF=JU;q].cbTE:- h rUɎ&3J($5)surB }ˇ;bYlSJЍv5#"6% I@G{@D5N-nwn'cuWeзy=7u~1)\~c}S H.&0cg^lٰ䮓2ʉ`PK^ᤄlRURrpeتbP&<|p@^—  _V~e~+g&,,\]]r˿;5phX>_KZQE,YVKW ytQr嗽_ЃT]ypV8A5ȖٯF.7-b/0jJ q!ۉZd^ ϭU קu2FPPzp}}FؑC1Y{ Im[I膀GwR"}lsG)QQ&uT84$,rRH{_ 0VV kiMnŚ[էOoC*^^Z9KN݅ɰ&I^Os HMaf5T:c1ysB.&ٺ*}uN'πnYo[<\ 4ɔ(ywJ6 ! L?(5~G*K\ FȖn7^rLW]KSXYJ ݢқSFvFǕdjKG~Fann*ѲӬFQ-J"NF3K?Yu7 r2HN KlTV9"@h] AŮ&khԐ9|_\^(h r, ԧ4f&PcYv,Rִ,RƜ̯Z*.ž{,0ތMPm KMӮ2lT N%#n[k{ieY /xKo%f#jԹY('>@tpNH?vOjWfض.y*ʪ\4u#hlSyyj'$|c٧P҃w XDkx"|z.wkKu8<[tb>>_O&7%\~:Idxґo HrvD<=}k7OYhn}7'M@;>NW;X#}ֻ$}yp^O. g Fu`gs1|sػN/`' )u.&[砮&8(i,Ϋ흃^g܃6j1 ov 9?/s6f: Ֆ?5O<.v 9*2ȵmSr L: JSs"̓^EbE~FFn=i8+~\3F\D.rZAi>ҟ{]ܢr%mص-.+{39*.lSދKL-Q&>Mɺeʱ3e˺Lvvͪjm2mNJ5SYQGt ixF<7^QAܳWӔgQ6d$} ]`Rs>tEN mSzjrtATݬ1[3&e_4D55Pqډ B 96d^`ݼ'/_MNVk&K9:O?~:-פR p`=V( M ֭ѓX[t3ܩOܕٕT\&~,pwŋg[A닊=N6_qao\O!9P/ZIm 'a\7G: S'OW #_r?ucJlnß4j޾7&$"gԧrUПVAӜ,qp 0 i+9FG_/7)loCf3K:iJVD1~MOfqʝ=V1Ud\Tc]֮z~8m*"6s-}e]R?nb'O['M8wVzZuA'?wC_vPp xnߔ;]~p>Ai{Mwu.i #}Ҹd64=ЊmۣɓHw\o#0{Ji_y؂2S169=?$[!IPDQkU|& F5,h;-h-A@=`٤OoϿ~[ӝg~~bS/Y>[;&mgƙJf V^JZ1Rbʇ%nCtcp9Pa%Y.JK'%g2碒H&H;CH*zs-ޠX\vG2n wbs8>H`FZdQ~zZ1Ieߚbh$M$i+ɇ|9GU(8ߪȀ5|V9y:PG"\cώ] IJoνյJBx' W蜞|޻_^PECʞG|ԅM;晉TZLa9٤XUȋq^dWyLޝ1U]ŭl)QPBӪnWfB4֠xBT6bLS*[(im(5VTi.yfPa`o('[<6.(%@!6H7s$;?vF5֠|tV@8%kvUt.n(wjv}Thl\jؾp#p:vxش͋_"y_IQM1ɴ$!>(KcŘghCY N:ԏ$u$):/}[,.{>4`)D|nTu\)<@ >4x^njb̫"g& O O5xQwUksA}+(`Rڢ桚@N$sudɪ=#'EN0Wx6cd韲?'%GQ-J;1KL󀻨3;2esB-3޾D=vSmDr$̡y -h[( " 2 ,,z '|oI{Xyjo.dwp,*ZO I/ݓE᪁cB9,H!-'/¶t8Nx%>v[h{i3 rMr!i$VVfr?̶ ;Ht(:,ڼ#zX7R?6M7ny B1XVk`(<(>GԒI Uk[΂U,3\#scXL$[. ߴTAx[Q߿ۯ,y Wb0HoEugmMzqsꡬ OwM~Żd RlA[!FL{)-VFer֏L`Z?fIv]UbO-rjs\)GQZd\se:ϳZUjU`C vBЂFE nuɷNt&C-B`@񯧿߾)E̝aep8ԏ[뭟19.r$.l`o_ϸzT1E)5>;?[6^omk~| P*;*aU ~q-B4 |%`Zƒ,dt0AbH8#|rJ9@LkRrSl Y̫9_*`fauDz>q^H`7Mi~x)K)P{R80gZX Ȯb6>gTB_OFP  `c<1i.0u^~Id;'xdk F(7ojJwϺ0EB>aК#J l¸3G?{}wOͣbeJmc/;,ŕD!܌V:TF'm WS=UV-9BI:t󫔞~69`?LmYH$^|<ʖzܡ*5;lv޼z|q)IPhj H+*btqh !Q_ Sm~ً<ɫrR"Ŝ(to`,G;oPjyo嵈*$CoJ8"4`rh0 EC3`Fgg<р.;ŦE9`k#{'o@o<3#cz{Ǥ@{, |O/[P6D qA#|i,9%P U/Zf1Â`k-:޶S>+`"W^u|w\7*l08P36w(:)@ѳ($HٱಶlX\Օ8xeծ4 KJ%[e)5xWGm#D&I(HGM/jPRn-8r".Ud9:Nom؏'Sg tKH- "ttpAhlzZ0Пh1yDzɴ7}MֵKWjh=r!W_0( ~0h} A|?<*F\Cȏ]( bժ39sW>c.Zba7=зLT@n8`jy((R:kT]U$7q36 'txHxYt}r@FWBԠ{c&d*+ No6bBŏk״N)X";seA ϡȱYL8Rg;ۀ%y߰|Jfv%hM I=?V_[ƛ^p {屢qG6\U3.Q\\=9ʳUOz-=b3gyOt#w.~_>kR a`ak_Tu[lmu"R A O 1.NB|=h5m '}Aa's{!@;% MeG2g~>sH  *ZTڔB1\[=ZȜ2M>]]λ3;YߊΪ(_kR4,@gq9Nն ,K뛺"D08*VGr0 XQ9dzx[@ 19"7_ڜ1r`,xLɍ=T_oES\Y󠋚=j5O5g):V>/'voW\wi9/R$0@r秩V#<8PnM N42]GgA#lMĞ0,"uO^*\\%uVIv2TZڣPcDFǭxkëAc\Bu1ڸp2h@nb64UWe =?-Ӛ^ѳaY3fmU PHdbE1 B&] !EA!ْ]Z`ZZ 1ΡAm}P&OY2@j,˩q)$,a1b#Ƕў j<\0kw%H -_~1Ia>.t=!5]T-E'VYZpA[O QeΚ)/O\MDSzΊpA20Zæz8xi_/b?7MDEypQRd 4l#EeAt.$)zMJs\QdL! pd iC)h3cOMn'9X<,GYk"M!-(O+E#9eռY% 2$Nm^׶}GGVֆxy_Cn"ZYWj²l0(OQQmS,<CLe=gj9`pL/0lOJ`s|'a D3H1VO_C]wm'*MG A hzzd0Q Fm>(N[iR^[rΌ0@Ad`P9,<gxx^Z xOx_<A8B-gX%B y BYE`eVVh"j/Xw+C C<'vɥ "A:~b,<;l$D9cKc}ƚYU1Ѻ!WW[GiH5{Ф{n}l_ ۑPKT* G{ B&*WܺTvV[1Lޒ:U%ѹPrfaʷʡ"s9pq:˭g}aD[鋌.MKiT^nl~>H_|ah'ݏz 03G '{r,.< > (-<(G -|S-T-wߖ*$VrL+ěrDZd0TE^֚ k%KÆ#6kZ+ Fz`4V!9C):t`D Y;>';U1I Tɼ&ObvFYGt;áp RsRmDWrO Qf|Xչ"N^T7q gSPGhGX[GC gWV[ϮxA1 `*sl7M_uoy]z1azWeo{iλf鲑Hiaҕ`oy2L#RFgUb7ON~@}<UAP i`bΎ9Vr1DAdǝz1K'+e EvJq $*cS ܃ Pkzm7-]p X at']#?!`tw SY0_O)Ii _5Cf8+1_4\D{uBpw {pLW?W9WU,L% ߁h{>/%`f0 Eձ("Tq5܁Jj[ts1Řl#c몔{#Q^`&-4G蛕!A9 {JLL%{#З6*jK/{_J^^Dk*Mhp/tatVnc#b׋)(S oKKm/CzRdbKk6Lql[FHx"/QOVW&=s +DEs{m[#<ԋ@'uJ3OYJ -bTM}^NT)9mPڹ6 \ T-PWrz7%~QĻ7P`y@ިqMy۟j|bca01~E=B<ɭX:"|H;xJmQ1_!;7r2ݾ4`i""~tܶk 1B[8Lc)BW( ˽W ֺ*ͷǯ޼įPLiRm)Y,JHr3tޖ_hS:kZiRbHXۧe aE`53/6pz!EnCjC^ǚ3bnk:vVI-)IBIxW *|ōP:$ڇb;Т붑kMp=6^Ȳ R;+eS3°]|OeǑ D#氮upz:$Bg~Rjxj 4M\,#w<˗\︜$Bs/ lR褃"Um;과B\.O5xe-*GS"xBC̙,Go4M i8vYC͟ɕWB ڟ^X35&Fh"RoNeLZ+,uQjZ T1tjx6ƗF+U N4 $Ӏ٦6uuAkaِ DXg{k_oyw۫˻;ߢMV˳Ϳli-@} S]૩-!3J<3uZ T t4[4>k4#woj䝛yb^C i uP yJ!9YMa䰱5_.s#Qi.=;'nƁڬ` Zj{s~( EW:,+,0dZǺoi0x 4P=\|sb.Nq+!гcހ}wo52V]ӨMzTu  dAb?b!7Sэ{"yf`T: 'O-׋εB3O7Nu굕jO w!cRLLvjVQ5uhA6jThx%dh3Ү&4'YH;% ND>+.,OY%:~Ni]F}_K <(l7wK,}m#[=~-%@2$$ȼ8^a `$F!~ߒlLݳ޻;3XUݖ⤝fZȊeF *ax FfX04  [Z+Qõ=Q6ebz^9QCZ}mb'ЎCNm/#1%#9egɕw!q=K"C}3 0BiNJib0!!٣E=&3 L0YftKb=PuELƋ*3VZ"=0/o`G-semv4"3"1FÊ3`f8+ *+!`%tfb2blYXjkTXH' R*ݶ$Uŝ\2Z}7 gLב @ѱs5r ӿXJ(T777TtjJElrrlHFWNEǔsIYH1*R?F?=+9"—C]꒓jرZ0Ol9V)@֗7%,_|;?h$EC6 N{u|b@nm=Gv1pHk~C9Feuq+(/.fxb<6+X-bV=BvV^C@&j̯yXB3N>ebȳk^ 5*j-T-TU{NU7-P-Tʜ̽;tc.39u T42YG etw&\)Lz(]UBՌ=,%_$yϾog\t0Xųr m;PLjU.=ܺ=G$p k'r9/xV؏~b_^S0֕K =ygkh}4FR RyhcѮZ{Qy9\puRM/3F7Q] p4TtdiTL^ʸtS5X^ɋivN,-m5>'vc[uF/~Egzݩ&ӕ =Q ir2@`ZJY~G]oAp1fNtn:2$=d %0ZDcY5K tJ/w%s2-Ii 6̡΁߄1,uxoMh ̤3̞0l 3k{^Wnď8n븖qvJS /4_qQ$5aH Ca("-< h1=^Bz'OfMQϼL>2"W[),J ăq0k++Fόqh6BςBLDϝ! {GUU,g6*!5}"پ6\7_tAq@

NJr:2FLThþv52Օ*.2;l_J rc^\ՔP|hxڦꓠZՇ@uS'%C R<`YBǽ{F#9Ӎ8Wf-z(ǃg÷ ߮2v'/qIEK1n0'8_O(J&w" ΨP-\ߠ^|WˉOOA}H?y^|}< }8dw ~,F|fԕt8G,ۼ0KWqr,+Hڼ5LO#"SOL5=~.R:iCiC6ܳ otM&+,t=*B&<’!MYnm*8&Ѯ{xB؟T$At4JAYmDfs^8{~<󐈪~\>= ֭2TWŧ3Rp dW9h-ꯨz(j.L+b̊fnjB6s)|t/ВA?FaCBa1Z @#$= re` +Ɖ+OׯD64Ah%C= hĎ:^Lh #e]ˌEzf K+U1hyM}.Ʈ.PLs/BYSV`V/]sg߰BWvYoT 3O%߀760uu]} C;'`=^]zu!CTjz) g4M`^QU,_*X;/Yߨ\^ƀe{׋X Z:D<5apso>71Dglz:Mn(X0()iցg;YHBȜL"VcG;7VVsfeI;@z[Lm* s˻)S^H1M0<:m;a #@1 ۞EZEx5DRBG)sD[}t6@T_2v Kn<\pĸ:%~WLΉ* j˽9`@>/qS_vU `:NU5F o&omAeqLF(R,E_1n_i"2~œV*ˊve&(fI?b Ne_cX*_]9sʪm[q6O6S nH`_9E+@e> Kk#'e1w@׵,M'0DĕDZ@dZ"빧%䲹G`\KkeRxA7(|ݏ`;]foa۽;1rݻǧrP'X58rDb}`c fAa2' bK!'<~('VZ"U VX7+w#ZBI j\躆E8Ԭ18mEeΧnk8 Uǻ 3v,`=ֻ4R ,W7Asx/a[kcԼ֚X$[^t+B%1n~8:qAz:uv( DM˩̶Q*'{9h Oݼ19|/QG Wb<04ޘO3 z *95O*WsCpCD9Dyq0}l;1|je(",\E$3sFԀp&DJ%E,BsqS}5z*W,8wt[95JZV ?6'pf]"P=uiL~`&~yo|! e][ FD ./ 2 |ۅw1 n预 Y20]NU/߂^or2@ L\3Z<QX!ѥ]^u; +,/:A(nk;u9PtQփW#ZyCpn*:eSC%PI؉ahg& k,σwWo>XLGRd3Rd"3Պt"3i"3r%R:%} P|Wڲ܃. g&xRwCQ!;E:J3] nv83s_9p WlShPc݌:|BfvE7{I?% ]%/8m7m` Kf!ȥCtּ8dN*6m6:<$]>B6xgF~Ko684n`*,fyRZk1xZY䬁K`픈lvBAĦmNw XV(Xku ӁmN\'hM'?V瞏Y 5l0C-DIPЧ^=Ң&nY< rr )݊#tE`m5{>MdMj{{?V6W hlwGkzq{ue?-id{sf,ZbP|J4%VWۛk`ڨ/gf0HĤǕZԠa"`@Vs:![Mu*IvM}~PA 5308A)o6;8Nk5X&duomm1޸_T|WOhmRZX^[irr7Ldr*(1,W$$,+1x,H&Lw } *H=&%M,O hqhƣ'\D4IAz-89,@Dm'wkEv`zoӔxmXNۡz/j%u%^!y},1{DMvty$ƺYi5z) ԸT, L[zwCgl'5PlzVYyp<9Y쐩"]tG K_ jG&?kUR0 A`^!!]v0'h@prCbqU` sM2o]KI>C)>c j0֜̽D6#㖂IPLyF_ÄCTh1,,&~Lz: Őe1=Alet"F ʌd^'_L'ʭ4@jyK\oPe"*?}I.C<=dܢH#IT\ o/ASF>ػ=10H?hF]ba'fpj솛#ʖr{yO6G&z䮙ʁ{m1׼?lYjg4hDEVH&W.TDYHT"qyd˔5ZJQ͂g%dv>)>n0e7d^p؎wS ">lʳ oۀ1R9s5Ino, xg'洿|ZKFL08RV$N l0'LJRF٘,\XWm@((oE*;UOp Q 349Xzۄ]q;>@qVD_)^C`mGo5y%I5@m:}IM~ Qu:>kM0mx-d|Yǧ] ު [8 }+QmgǷhbyCQ.k.MXZh7nfQnHd=0>o>7?[)J޺FGےob+5pބMʒs ?L?;Č~_&S"IP bbHڤ.uY-pa:Q,wԤ'jՐlDc0Ř~F=W+_6 4=aºi-+J,npi)8qY+ϐ)tLz/yL+Pp x@Yyk,μdfCfOÀȚ6!(Wwv4IP ɏi}UD32sA5ozQE|D)z75>Dp+9K-vc/1P}p6ߩNXe5H3R0*35K3IV{5RSګFy/:!c1'|~c)Ӽƻ5-E. uv,FJvcVkeԇbTӈwRurk%hԄ.抭`AŚ4W(ðjѻyJ s88\o+n{Lͮfh-/E(mk^UWe6Cn zRK"r&}AE1Rd@r$oj,G\2̼fpBl!1C0 eǀr>V=Wis5Kcʧ])ǰҳ2n3ǐo@xspYzn Zm[HƁc 1GحNo;MqQM 7>pEJQG؝RZIANP^(Z/2/k~uCTAX-vvsq節v+9"wKWnT8y˕U,$"ԋ@π\۵R>᩟c4%b_6'YPE+G`9:CX$t.@ca 9.d4`,'d}%w(*Rӳ($Wy{?V+gx fPovj]CW^s4SVI9:ƫjP(SĔQ@,\:gf4"Wt0+ϱ93V*D=全ɽ9cwvsu'[R02'.)}4gqj0II=O1:d|>*#d1rGt k2) &/o?7;|=;jajۏbG)I FI99ggԵT3˧6ij6OwEPwȍڃ/|<kuֽk v 3Чkv :^hЩN]U՞~A;/=9?0; `\2^aԾZhKdhkwXRߡ{*ۏDi {hkæpEq] nAhfS9\Q=vWN#qPv\Oj"t_ՍQ=E1MH.O{BL}lTVЏJl$ >+֍sfY، U06FHEUfn7V(e^ 1vDQqee'KрH~%V[#IbU adIiPe`,{xjol6ʵNAqS36*Yte3=QVE@&RMچxCXr9ҁ@CRv= 4} S":a dgWv|ā`62ԡFW>Eo՜`kkOW;i51*؂.ʆS|AH1B>}&PC/7[L7Z1/OFgqDLz2\/bϺ#ө"?.TxDw{^o\rJ.r&+gSl4uQҹ~s3!Aaw\ҝ64«Px{OyMQL>:^q;^] y$N"#8+D3bČar( nyc/#YUL; I]ljG]JYu(^n0و"/&`weپ)Pnt`72fF;)SHT 2Dyk oz쓷_ mo/0O>`5wۇ}孳7#p_^~Z-,5ku^kk1r7ArMv c `E!r5kcl\ibl\b`ƌ3ocqm [ ;)'evB|geq>eD"1jmlMM jaVu HXkuP%Z FHmzb x]ʍh7ʩA՝B+31ݩCg9x{Kͼ楃iLx/w28􁬉+ޤ^Ǡ.a07nlW|tVZuȥ7LCK$N `h5MiӇ|os_,Q% _1iUJPXP;i(N|l̽DMQVrl0KZ"o%QWIt wK1`Ap:5!fZlK|.b֭toIptW"һ3!A_Eʊ”G"2@Q#WTs5@|wqW5Xh0Oi+|7waB'p7+BF8yEZҀ#GVyK{$,aA"݂7 r͇QPl O_߇(Qf^7uY9 jft]K75siuK'X(BKj_4Lr՗FAWBBx3 ЫQhl0&)uoV^=0KT~O9T84OEdң[Y%#O7oU  ܪ&ˏ?vZT2z/= \WYRHFKaJ۲JM|hoQ4pkkbt ˽.Ky3,ڟy?kST*ǖ،HMDz{ m:Tn6튓{!en8 dQӗ;ORO5"ȵfW9T-l9$ra'mB7mw_S%)` xPD΋ASy Uk>IPvJq .mb&- 7+a%^]镒{rG2-) \b[R︰z)}8gʓ5rʷWkN *QߚKiX GX'³i=K?c'z2<'i]k"7N8xn_4t \z"; w,&gzy0+lmR͞;/|L)xsqoJ|@ҹ`lm2''g| Sɩ1 OyKILd+0<[rRvM&aFY֧C(Oc\]3.sļIHx7 hɔqy km䑗SrN\W_2H8 wjb v[녞t[zΑj\Ⱦ ^Wj+\P4Sx%T&fEn^řz-h 'Xz[ zz|Z5iZ$(f +-x׿J@35M풬1a%Pz ײŪ4T|U]sZ.ymkE._qly`JX7Q:ϸ_mؔu m|G+%^؍BWЅbjEVsԫvQU.X(%se+Ұ?K<t?S:C=; ϑ{ k+>cP.agcr| oݚ'ى#Hf&BߒZp`1fbfbӂB żF$翚{\k[pxUv ƌƣp4[|qE_L3NUG8LU:xiuH!_ZE mo;be2kO̩XH5Z9Ebsx\gFer#2_5xЮRxSvnXBiS S`!e#|s}=_bG~3#Kzcn[{)ݎCDoajS&Jkn7D0obRtp>oCΕz{DrQ[hpuA|ٳt_؉+;N{nGG̴a'ϔ j5nIPpkq|8ou!ߕ8RxL0Vos /VePVr{ -迤^t'Խ|QR|{%WUk4?s:z-#E4ZŸA  Dx]u޶;kmݖLn7G`A.䦐7up ]zc`=bw0jy;A+}[~n♸+SOvzyпv߅_nEiGǯj5wǻ7\&^#@elm k4tw]AkzB=^ZʴNqR_1v*wPd]GVp/b<qҁ=.'\šR\vY~8/,nEOdg)P3q6oF^'a-^-.c|W^|A5T@@V#0{P4 Yvw9 4墾Ze K5/fwy-[S IS`S2id+H tzz ʖ.YB d_#p [ǶVtl Fmttf40$Lt6wEaq_i J%ftq /rf'.8tOǞ;e<;}_}ގoa]Uu ϚZn=Xl%6?n |.,wC^JH=2T

`W[mFK:4w6Ȑ;bBM0Pֽ zT?lC ;A39W2 t=`)ɀFVKq!IDj5c|7W?5jX uW<-1@×P.D X`y/:7<S=(q{͋wZSu@N9cQ f'Xˀ9!Fv򛇌{nR$zvD#Jm&w9C ;:Hu0W=!@8 v7ʰ58OÁzi͇h `A%`"| ߼ʩ;16卝8]A#dl@D6f.X9 '.|?rK, ֚BDI 2~HIpɗuYW ?RT ;Eyiy3xCZ .@-yMثZ|-{C2Y㚱UOcGPO<5O`OX&q;6_2Prҽ"e Zv9d\ZHz1MxoCd%kض*s=j_]Jr~nO|>yaf'o=2d?4^͎A%=DJy}/U|vG%sk^"'%FOzQR1Ş#`X1WQ}|ǝ,Zot*}a4&|}gz~ Bu~f.(* js Q'JA#Nɀ o0@ۢ`#I5K44B2|7C@M9ԊҙP*%[_3uP~ 냭RͲ5iG4ӆZڬ{G=Mvt) Iy?b|OoE줴.އ#W y#ޛ(hLŋSAkgwg7X$owWW.-KHUÝ??Ƿ[x /V'c3[kjmH8gò~YY3@V5E^h(N(r"3DNZT =?+'(:&߄L)$W\shLۇk=H7FZ5ҭ#^a|kD O);X|kRޥַ&7'JSr UV(p]?V8w`x @!^}QCU\3=tix =3j%)7axШxd⥻ ^XU-o}ԜeT@~A|N4\ވ 05d*W-I-ݞv@2Y.no}2tZx=6*hTWy7ʗ71]#A\ЦvmTl tB#w=j;&Inki c:)úD03# eL9u[,feMQ4%5tsv;sp$}fS`> /9(XƲV?]㇇^i,LF3D{B.'~.B$x@+n2eЕ IaB0$6t?jfN#`&D4joӌ'rY\15zJ|?>{tp7p>;H `*mP%ЪXtO6V8m(̣EMTed79A5/, ;-y6Ir O^Hzl[ߢ.'9^X,㠒ԎT2q45b?-J=3`x4&<)B6wXoU~fi芍΍~ + o4=:kɔlX>&ipjf EWVJ{ɃD7CGAF4zVAuf96[GbF[pF<-[do'Jrz:=s**yF2ls{J"1l~x+%%{`nά qld9[H.3{*-+wV_y/-_ďGj7NJ-`sV)fV~jvp*ɉ*= F9j-gAi݈ߤDF>Vn*g7z328ڞz*\S2|u hHQz=ȥ_50Ds9}?ۮl9_8ToC!x,#bg3% ,0}@o_8:EѠ2ϤCn˕ 1#.()"mT]эm@if~FN[&X7q%R( 2>;F 4Z3RF {][ &G@¥ Ϲb(YRC<{˃:Mr]/uJ>"B#5j x5 , WANm#*FUrS(Ĥ*秨dE3&zwL0վeo"@UH*w5 Phz+GnBŮ2.' h͘c1y1Ay1H )inX {bUjdeG#b*Bxd$GZЙPRH"GC.+%qNSaWQe Xͳ xԮ`R㼊Hqs1KQH`̋o=@U#1I& HV =[%U:NqY $Α/A{$:.}\)CƔ%i࡯&s7fF*.3 (03(R6e-3+(JW-HW (QlA/Xďݩgf\6 eKJ[eh$Ch0 sUTZE M1(#So;Tė䃫[M^KjN9׍!Gw/} RwFˆqUz;q'd27_$gwKcS0fA{w$O&0 hOz`F0'qCėWWS :bV:F x>{I0YK{FCn ?.zpxɂM'~ ]cr?~bzg _wE;+ [-ýިy &Pu *@Gsqq$h!=9hWE]}ԩ^~ g 7z_-S#i)+/ryS*1pvI MB 0(1T1eaL=0G&Aчxf߸PV߷Q[9ZxH]eRG@*QSDeH|n.J6da9@? xJl eu(ީ`v4 i Wqx54> 5NOsud"= 2pTTQub3? %J%!Lt+xi $>Y04|_7~宷!4 #0FxlGpL /FJacCGnߙ|4992if:w:Y:~@ D=ʹZnj\.嶬umq~)ɞ;tp״eDWd`Q eiBdqW.).5 ƁzALPBmNc+ /0X[D*DJ- @ЦQޝMҸбgEd3/yԄxeRYVs 6.dF85FR?yQzNWV'ot`xG(Iixe' N񘾞" c3v.n;>_'?YB${&@񃌙s0du! }.lvuWH{Η;h1Y.je_̻C{zK3<l }u5pFۣ/KSA d Dw5<~; 8c$jƒs2$K${/^zO?y{/>韌/ÿ.&Q|wfOvgeum}c?iwH(zrӟއdh&ylWuUY:.,ٖ淿.ӞkdddDddd$ qHt N٘;'Vq~r^q02<7k mƽ^(q=<]%^MCǯH^faZJl&z$nBJÿ1.x\Iտ'{~|xNX@$E0*yU~*_JZ#e$ 3k;k;+Lj "5!NSzXw U @rb5jBXΎˋvM2s7;C܊$:*/⥣xb#b:#~XW'%wx>_ 3;yIɴ5c)M-k=9gE4%\z>wLaώ2%ߌcԳ3p7AUAhj{~A45}BVXkՠVY~lT s[0b@Uk6"u4 Vjh4|QV40*$dvkVv ֛0z 6Z~efz}e0+AVe0T xZ& jf5Xj<WZPy0h {~YVڬak8X z -߫qz^7jmCnת*6_)nyHU$(D(֚-?`vU@l h@S8F0 6<ֆΑLk~Sªke ``⼠ ~3e ԇDڂWqPVA\x0&Il/($=P-ȩs۸LuVr@@APmy]= u hM]yFP0 S - zD ` 3 tk>9Hȩ&Pl" <4U `aq`zvn<Z, F۫B-$*!`"H,* kqM~Vc&BmhVA~ ,h[۪0a*._Ya9d2hZ@d@b>L^ Xth{x=Cm9x*95ք"V=\A[~yMD[Mhʃ 3*uֆIG2mCg5h5*# ]p$EE*ug``]``ު"iQC0&P-N`yN Pƅ4i?L.(^^ <yrF@P:` Hrp„6a!U "UKod0$+ 'D\TͶߤ ~iRmCʯ" ӅG Մ`HynL=@0ȝ6uhn\|``ms`߀]S] 1@ dH;>]`0 lhI)|0:0){Yx"yaM34 kVP`@ :P $obs6@jI̐Zo@uL 詅)'X0~&=(42Xl{4ZшҺ \m5 OQZ" : A @RQ@cU!\3&`Fl: pM@m_\Mk#@-&PPH Ä9$@yU )5r#`PAw%$ 6@҇@ >Y8><i0("X9R+Eh(xc(ZET4`!n#A@}Ȉa,muxD0wڬA+6hR:2\ VU4TŠK[GF7]/iFw{> nӹݢbxn<4,6a--h{n3j3&"mhC:Bڞ%49y;oҭ;|-92wtN'c&4`9z3J=[KzwFh?+{e>ۋ6')^{.);*{OV)>q/#Nhאme? ͵/6C;4EMZHƕNrxr9FxNl?K8 ܎Pmoj;r0%#@q_0p%7|M4]tTDDNǧ#Rljz^[݊(U_9UdXV[AL|جfkX(]oDJLL{P2Y4d̬Z>Upj"-Tos,L~3ʹdb,8WqBfRFUDuIj 6M..WCfBDLqg?׳f Q~zgG~ˏO$T5ȕI ~}':%K kd 6ٺ;Fva[ T*vg4:YѢ+兣@:bd  K[9 @х@x]Td:A ܾa.dtחaz' ?_E#j}ZFB$WfC)-P,$Je4;WA<.?Tl#v5^9hE#;]<QF,iJ1W6p 2PLI`ٚm?_%,j~)o|[TX,*1f8~vBV-5H3Fy}PrGG_̫H}\js-2hAS"a~MI5aaRDs {XQ.W4 :^7:֓sA?9br5?0ۋ4]3vZn WOh7x2E`Ebz]]\I"E1!G6AK˂& !BڴX_Տ0'ޏ٧h'Hs'qV'2 qcȼ4?mU|77w+揖*7M͒np Q09(T]iYfR+' =pWW%ݼ!4VdH6{O݋JP'.ht(˶0\.ek1i]4 6fu)XߥYF $%ahu avu,xUd/ tL5OE1yڵ^υ硫`xԤґ`c& ps"\%}w9w}ժ:h8JYtEXj\&')V􊚊UcSl7>ahpc΃ !dD Ρ]RA?FꬠY}0WDoyN@ew|l5,~WE ?p}R 5OÅ)FXW:KX.#z<|B YY 'ZFv֤uKÏ>QM$qzx6􊧾-ϷL5%a_h:ҙAɆS-^Wt<7|qRgᲚ[^-}H; Π']C:+Uɨ3 DǒfY{(%yj䍃_3+,m3rMf-4=bg.(._9$A9N gXN k|Fj'C=}_yZ$@0]ŗb;' Յģ8O_/`3;OLX6OG/I,5~ȿ(GPhAt9rA24ЋJݼj*ҍ]#7Ij$1z |AHEsJ0S Tu4 g.!Hv ҕ. T:N0+:[m&e:2%~V5@e63-V0ח|!~`7GGO}x;QæDNaOӌNdby)QG۱;7ooQxsc-ڿ|co f> #뜆yFJv1=n8uAW9ޓ] ,']O̙|Y8Cy3) G%8hG ֒Qۍ,;ܚ~0>C9K(zp_UӈOA|ǃ,` 1TiOgۢ׫/TaNcH%ρVa9~I:xgdgT~wv/'g 3%E+ˆJ;JYSױ8M)ez:LiULKUD uL\f;#)@'+`1?#s2ð/Bxs~~~6츬MF- $b?#4.phK׆Goڝh2i/ AVxj*A? ׆wͣb{$9-*]2޸;2Ktz=h7B.%n>Y)eXF5tQWe'Pk疝Q x8r^AᏠCWS I.W~7MՃ#ry&rH, AQxdQ *fYZrU뵲` vŝkv\t2a=czwtH,A2tnR<[oR ̰t:4Ƣtce,,&ҏ(K`b/U >U cXd鐍{5sX*Рݴl~J?`#qA_ib@p ٬T&'pRc" ^JG»”R3}D`%sP8кY6LОI?7f5^pP6숴L^DP6D5w:خDx6K7V-Qx"Җ qEC|+Nch;=XHvq`bvxB>aSPmWTNVlni?2v SJi#+*|ִ6֣ O]d-7%HU-ir]Qr!rSqҭOm0 ,|jX2ɩ]៳1ֽ{uqts@\#j؟.̵w 2립Iq*Wfq;$&I9fй$ׂ0G0C*2P5P\g YF"1RryMN|eTM;zAsKMf}e6v |ݰs蟸{H}(}2{|KC|BNIG2@eV._v_'XdF\JA/[m)(;1:J?[So>]eYanEnw12vbYc'ׅ|qkZѰpjGH Ǒf< 3*]0734;4 |1[86䭨7nE- 'k5.ukWhiˤ_fH`Ͼ[nܾP :Sұt[xd9jt:\:morWֹ"9߁Hh3TC l֋W+NbދH'hŒg#t~3G}덉pm {3ܩΊ|fx(.GLZ13oj5=Jqܩ?x,zw]q*Η[,QrƻPʋ7_%Y?keT':QQqP(ւQ5tfH:_!9 =G2r"qEӒg|%wrg %?s {]5=qcD>$ˏ_;&󢢵\Q~pca}qȳ]x}bzod29uvڨ8_%v%9ǟw# IQ:tn7PX)u/ kݝZvmyJWrn gR^u]BakmQ'wb)׽:_`GB1T>N簍rLSo6n>y:_no%K^vVOUb0jK#Q0򱓿i4B+^f™ ["Ғ]4. w{3r4OiDFea,\&U" :δU 'b|%2hixӀ8?<ʻvqL3ݙBP!w4i~R`O`oo4[_*ӕ)h*98yvj/wqSn?7'-HEe5#£ w[] āf9jw'9޷r+6v۹}+d 6J;138i|uulc1G.MHzVV6+FtƦf8̙7Sr 1j?(Uot gC5fn:Ao]e`z^U;;Ln[6ϖ39m#pJUo ^gC 42WtQ}h zFW]jFEFmج7}81n蔸mxb6>T5. v)Lu?0oF؟Vyen4P.Y RKY0F( Vl#4*:Ry=^߻s\Q6^e6.a3 ROTUv4\O3[.5ڑDMFiif8b,<_0ir 7/V_E14T~F,2-3[|/Emߩדܼ& '@pmpȘ&B{ 4Ž 41YC4i[m>y$LANt ;ydi 9O6H@Al.H/mg|Y˸7P(:;Y1tsڌ9z8nQPw˺ѽ,ƬLUXu4м"*FF0u׬"Ah8 @T$C`}+7o-^75w{cyK5ܽFjtn]PƄU5r/:V2 -3 XZNa9qބO\ka*X0ŅтcIK@{aX&M/~׎WPЄPH`1+ KlZmŎZFH9r&}`u,ajf4HT|1M굆N5[@5 YkbNjr=6h}h7("<&~\ ;EǓ3X-߱ơ #<;GY2 ՎW帏vH4&ϝ3]Q? lbN_[*7?ÊְA*9fJ$)kz%2 UyDc' Gm艕9\ζ`Q ̄R90>@'e SYJ|0%>R*n?[X]buwcx)*P5[F&}Q?0c#n-:s: D* -5`b_q ZFiQ#0l Uıo^[--GZ$1xP_*aŅ+./Ɇh)]^!0.eN6SqRc4F庝[K\_L1y6X{=c qHDbflvx̤>>x ]zиT87oك e8F!yH^JH 7) =ҿ߃4Iaظ6t$ jr"Sm@T(\,\)•i& iz=N|n9Qi}X orkfBz-{֋{uax8.̖tٌ_4SkJtyr 7UP%X>4hR~=z9Lu9mPs@A$/()̋/qT_yEy{Տ^na==PI!Z^w%禼py BenkPfL3g&\tcʇ=zG\)0םP]p!:{_ڍ{xFb(1KUR`XSRvmU1t ʢٓ)NJ 3@U3,ha~Ҝ@f#ETG%NdqN{WL^rt t_jD?FTѷ&nDgZeSKi{&BI R΂ Q| )-Ngc/ +#1dzpy^GˊrP1C G&H"!F,uUpZzR'&ۏkQ4)^ $R~]J5*-9Wܓ2zb衏ɉiy< AvRfjl6u\ݘ?^d,=ɆAv0؄3F^ωR.o6gA6IPC=`$C+XV<z%1(3 mصs.U=<@rxwًM|z[U[..4UJ8(p|MF" 7, xȰ@4V4w!e5ژ`q:0x@C2б 4t, @C3aHxC"ODfD6L5*Wbvч<6RNBs}xԊt\{(% C(w{h4U 1ȥ^MC&f1=4&<ЄK*u8T0o6+Hd,g,c${hct{h8 WdŏYѡЮQ`csR~]Q;OIW10|$LzAZw@W؞C@ ^y]T&n7wl?XN`VƕwmhpuzدGP)~yD|W2ʔ?zN~هNZ{JjͳgO|Z|o{N3)h\~bO߽˷o>|<,V=+;xgE?>ћo^BL g(R3}|/o??>#߼j*O;k1A]MHs慡u^%t(KQnvPm~#]64ysE6 nV"4 #oFqtU@":`vFDpOMQp*e%V[q^xn&8TJ%(.= Y˧ަ (Lksz=HoT#G c/ȆI ;]$&`dm4ן Y?yZ$h+ĠWjSGx~x݅$2:ڠ?`o%BTbfBK2t {bƁLT co/ZJe7BB6Z=WE&xT@z#zfq}w޲T֑119 #D҈m3F8\Sj4|_kegʺ2!IiXc(F告f|O2,IATYX#Gxr pt/<EA<PVu˳- Аaׂ&ՈSԃm˦bV' v,ELiƠ$hH'B􅿠!~򳌾rV4"c^:jQ8+΢2m&i–X1 .WkyA\:YRncCId(0LāFȄP PIٙ _-N,MmW쌾  0 P½42d7?L:Zу-GeEb kqGo1[1h D"ڤiy.qTdd#~J7]WPI!fݵ)lv$*%ZrDkjw8ƭ7T*S)ܮXF/h7C|7c,I.T|}JZ&Ҝ4'$65B"KL4eR"fp!F"_>cvq>z!p߭IT=m2&}G:e5?;b| 0ĝ2A4٨`Əc=Y* )Xo=VkcKV.APJL܌"vAC&=נ#C$dWJhg j},5 Qz`0ZfBH$.mݮ 5ٷlr 3DW~ ҅μNJgb2)Uq 58!h><4cX=yrLJ ob!͢XFE-b$M86|@k ݻx\n?AJOrFkk= ׯ(-bf)Fec=R]cF8)-I@:ޅS"rOeFr< 0!74Yk4>+rLc*vnȸ.NnhLÚ5l2(!vܲhq/E)Nƕ4D6L%y!п&Nz*t:˒]ԷufEn]Y (ݐi H-!Zw,؄5T94њ(UQ,e(ՠp^L9l3[ΦN(m\暅qT<:h{}(dKsm3I~)]Cf'_ċn‘E\G:8bB}G}]$#!D%2 7˯N& 'fB%Xc_ld2subq h8f&ֆʎ(Lɜ.EC~,t  OS̀ߪ ^͕^0.rg v;,ְg8Z.65 .lAmG6 Yk@m]#dIPr +QMC6;)ZWWޯWWx㗫V/rWW^/{m5~r nRnW{n{8pf\TEl ?L2" DLDb2`rS~*[fT1cTWԘgJ ڂ*|>c-`>QVlg}\n`*i1!N8GTssaB' &sr.SGoxDIINx `2Cs2yDvu5L! ׸;M2ʪVl%TRlN VŅ&$9fΔU_?Z.拠`#?wWM5[i{_"촄R Hݣpy0J.9~cgVPYm@?U ?Pa@cȃhJvU63`YrFOBn'jKXQ/`bt7/.bWwt/8wVZ5Ŗ}wΖkXT*\6WuTB"솱ui_/|^A&OlWt$\Aaمߠw‘X¦CT&\Ƴ'uJ{nkJ4u`ޜ4Ȃ;ӨNi,DEãCm5oڣ-;d#!vbJ=u,wmH.gQE :OߦhaJ(bue_-S잿xحUETqx'`Kb쌭نm9.%̾G X7U,tNyr$K@uߐʋ`pZquq8x+7Q+NFN~\oѧa lTzj>bg됶3ymfK) $Eiݨ;itZ[R\)\NKa7&__z()rVD.;I=p rI@ W;}bg`-;%j-ӽ-*|Mk~pl_9CD3Q)Q.%|좄psQ!]C 9|@9 ~hݍKAWwa:EΎ1B>[;!B0U(9)F̴2/>W -&Z/+%5ΡKzGnAݫ\/ghɵ]t;%BZ wlNtM\`{0^Еkf7}A%^1WπluA\'=qSƥqo g^+>]ViK~"2tظN!֯㕵۩5QB9~Ui#"Oᙼq~uOW*5A 'Q6K@8V$Ma#FeZj8{r&[+tCX?ԉ{ ZBs(;qij#|J_|)\6 ?a2F-gŠ#IÈY/UO=ips+f:s')M,e٩5u?pA6(0ouZw]:i b!-jBxdr"es2U'HJNaYNT -ԻϺ6ϲPy+RWk+\grFJfkXQ3NNXSޱyU Q/}g~JTE`/u$8wEbF]}k>/@aHnM F@< 2ʺV&@]4z\d+*f1;aUmc)f1}# 86''q8@KN-k6oE>ZVe~̇ %wpVDGl!  XoȏM4ݧP:~ft ﲗaw٫p{c4=-ءm.fC~M"PX¥W 8Ң~~3Wl4JS vxdK1#(%B>V N @Ruz<daPQd#~A {vOCn0/`r2.>߫‡@d$ᄏTmz&w}*lǎTq7Xn11+dËXh{27Q䋙spQbݗ18D;%y6`WoVk5R1{r6l!3#D %b@E4&͑k h]N@B'VHjE#yG[!;a@Ն&3 o9o^7Fl9΋?v .RW5bHeG B 9;à90uR5v8̎*9UmTl}S\U)CbԾI#W}jgc +h 7gVy?CDl1dۇRO11 60띑ݯ"CD9#Xǀ8X#AX 7qКMf"։~8'OgQ^]xnNɔEdS^#oGJJs.4*Arb gRIYgݝO:ã+X~{ʸ_Ijx,65}pk2֩{Y:}lN "75fe7j?U;nzZg n~6%bQc Z+J“E,&ho֧Rϋx5X"2Џy+of$ o*_GV6;]וF(JٕO&&6qɹД巙jɤ9v>s;/ͭ&}(H,VIGq,l31އ('kS6XvbS/{t}> ru41g1 WLpvA·/1//}՟R9 $m4*@VrxfSut7scs .E}6UUhvOˎ讚$8);ل-h%ߵm7+@SK;'?&XK3e4\uXR9XNJb̳^mta>܉huyQkзH\&y5FEж.sup@qC<r!,b8V,]|buʉ;jdkV@0D<ʃN~в=>l0T+ܠPx?г ~tqW`Ef߄vQqpMl;jYwbxzNek ]ŸأF ?g}w^{ _6 } uA`/?fc{GجB?`*9k{5xɎB^o( tM4:Yo-H"YËdU/8l]v@7_uti fRX'=c; 1˿tSkh@݀) (IjЩ6/qC8BԦ1!]MFf@#\5Y *TcjUVYPPA߀*: ,^\7d'܏_2? 7z3`֐͡X8BV4~00VPe *TpkV]5Z6z  :8UWuנMעmרK`m/ |8NS\.ቛ6wt/uF&|{^x4)=$ r}7x:ww{}F`Y~6˔"|0Bw*:};~ 3z ɯg=<<v,VOR"*<ҶnURR!_þb` H, )(O6)S"ץ |'-|AWq(P_1H ~c9B~$| ʧaqY9k8xi zPFʅy;JRx3/wT8Zg~gJ ÜL=HTeZhRj2[nY2K۽EXa7:dŐuGOh;Oţֽ.]F=tx ;_кrֽ >HTI0p&yoE &KvSEfg9L*?QgrD(d%?J l\K,^ -"i{~  x[tn!;;`c`ravj7ΘlG5&+aͼhO WdKNL&cRqs7f8?8s[$x0pRN`!!xhM`q6pH pSѩkpxEl$Yo+x3F^7:|r1Jc976Rގ09fRLv狀|}zxԮ4e}F1_tq7&rEt #  @xВxSL[i2vLSi2m}͝4f?uit{ jȡ8~?gc#*~tѨ cؙJsM>XQb&=H'$fQQ. $EVk)s9 p\8Wb6ODF}TƌΆCzrVec4lHi\ B„$=@Ik"QlVUXUa5}rh L65d4ݡ"lMh.6Gf}*3 _U1=]KD%'3V]00L;xU&`h*DJ|ŔZ6TcYMp Iŧ}0<|E꺍JexU͓: 7#9JXjojĎ)>}ѩ>dj ׶+_Uh [W܁u`wAVHXb@43L0 fW[Zp;0a,TLrZR#^0.nr0\)RE\U䗙c^/;fŸIok!?{tRߎk?PG14}ue#,k3m+#hC{ǨV5Ճ_;Qm9:x/f*J.1'ע2U ш>k<6<( 4SJ;e:}aIe(=D2]d$E\WU੠W֤$yau8+տ8Ic\f4  TKhRc5w  &E`OL;Sfм|z4>yj#jM9V}Ƴ H~)7mTӺ.ෆ<:U+Jl^@Aa 6~!';i vbxGp@+_Ŏz-nTq88ۀwjliHSN7Pܲ3W`Job]1űW;RTwbG 늧z^_¾ w?aO>w$?S_W&3zU 57D_YMtgxϳa;;C}=$>Hh+eݑv{71k"f֛k4iHHf[̙c3:;9'1pY螱3ZazXbvM2TB"㛭sx5zGM16M1b"&;ŷ͆(HQEFcO"p>־gZ+`(nmb4ʳ !g|sߴl}qE4g`$e yV7% oau}u`i\h~NCBOuzv>[ٸ۵7w!?WC8H!S~Ut3ۧ~qْ;|?SN*Ar\7Խ q8? OOY(T?A-o&  ~ӽNP6Y)t2ZwA=PWW8xDt&o ]bEF1,d$}>ݽ=]`]:ztDGGsY _M2MFldF֪v}cbĴG..X&4ODN\j{f8U<8^먜D3pp%Uu't21._4vBEQF_C{)6JNa@`GF`3rnӏ}7vB+"Y'/bڟţtEr|l2 o5ryؼ&? HU06OVS`R_Us)tӡ)Lʌ$撊F#>qw/P _'ɧ+'RFEzf%qLii~;Y⫏:Q5&Ia&dSđ!Qr pf5 lDѪ$aA{]FCw~+;叮BjjפO䯬iu4X/G<4hi^Fd٬ߕ9;t>&UAGR sMdeVbfyeCז_PCG2F0@Ag'"x&y"M"*'OLgx5$7@23y$´cp9]dB_ -O,ǿG%(BWl;`G#{#x ŁCO}Ȯg9>%H) MZ.wD.Wi d`'%$?tdU7۫MJ [&D|I&R GDaPʋZ-^̕*D pdtbFA䱅Mtg8q۝JfJ0%\n-Up %Kz]:z< k`))}՟}twv }Z|}Jan7^k,LLS 3;8Xbo[- {>3P&.6e[+q["h;QH&=94OZ@>3߹>TieIkgLUN2{{H7 (o[@/Iӎ C[F7yӨa#y}[`$RWV՛i2"Xzнzjܶ yxV`7(vU:/|A1:aϐ>}c>rmg\mv!z͍,ZOĵ1,R1\h-99i2RkEy6 eITpdZ% Atz:\ex)q),x1ZɱL}hJ/>?ZYCr}+勤W{L@F8C ƁnH[ dHc$ vvNVW*i5  ";p "?kL3 ],0%nh"gq6.y3H0Ј$'$MiL,՚eBS"5M @AwOҘbj+e,Pwcv.^ܴrY^ ]P8,nc]\`x'gIGj /q $OAɕk{Y⍶|R*Aq񛖦^)\>6G?h67ZCmގQp>2ZpVZ P%J9v G]2tlaF9&|k|;,@_N`mm֫zIay2SY\L eiTBhxդY%TsCtqL|(!#;D9Mg6ըN~>X$u"Ʌ&6١0=gg?2"M)&^IZ<)"p0SCZ\QN!FGpgyѣzCYT)Y1=j`.lw@hbl c!Z̭wnFEWP^dԢl'3w9/=(GD'+ksSdK"yؤm/F,'׺k&BP(B˗coZ60AW-锌ݶfyJzMO_~No]zϭjuȌGc@9~؉ oD]~KETŭ~]o?|!b2k>caqË󍫸[w{IgcmݦDI"''"}wNVEgD7 ؟ӖFE~߇7"Nk{`ss|wvsAJL~m(π_cΧBՁͬ#X/ͯ>Y=AV8&d%l6VxbRk^7A.zAF9UVfo:(xȻG#:UPNDT_tT%8MҴ Ei[>NjbEf8!,A4FGOט ^*N' B]g^~[GeL@ z5Gwr:iXW,>nP *ϣJ47v')%Q[&xLmSk1I&yĺ$=p Fٌ]ճ-Qk"h0^M5tsruOǽT낃I[|aFl~}4S F)0zJ[h|<ȦիW}Wi|u)Ybhq7c(&j& yոLW1'Γ`6mFޠqۓQM U*+ȱ`t ghQz:7_3W3]!z<֬'ԡ\mT^ ljUwdF?m5 CA ]z!ηW5vYd3 ޟw~::mw7aQyQ{[}aV=~)ZۍXAaáM24svhb4dݳQC܌7s3 Vؤ=s5<u=I pP@!P¬R_'1wh=I_2@zK5E 2ڶrKz)mw l0hN!L4-* Sb5!{t,IROFW4y%#eLڄa1)¢DMIW |8_4#Z&:9q, Cwy82^or{/nwNۛ' UhS\p *U aae=UU]D(*lOō^%ݣ=l*Lw?|ǃn/G,UH!ڳو6ao1)Ztf(Z/fJvpz"Z;BdKYd4z ݣ~a*FТhozV67ܸ7na,wtJ{NSr&Qf8׼0R]x#<:>vk:=fH% |4sI+;fgf|MRC2 NEszΓB`9I?eY>PAc5-MсR"aQ:Boz\B)|lbgr ,DJ9[?<-+la4\:G_%Ňt~O>sDҧ*ga!)߻q+θ01P@Ι:dBͫsD@ /_+}oBSMz{~wZJ^wy79*Ene"&e\=<`ިGFH2igcЪB{,DEa23#Z$Gݶh,:"O22ʥRsD|,l.$t|>照!#m)g9ea 5gFVJHSNj3UK<݁.&\Nĺ\c6~A.FOWRN7Q-(fc-6CtlЋM_<:@IQPI&R~cFCTTm!rŅA}fMKihnz=}ϋhME/ݠvZiQjZǃF1|RF;ɎGۣ]˕9oNp7b 4 1eq /4 [*1+pqیêx{3h-ڲ ;].0>ۅItF#홚LCg_m 5g$>.SC M*'w:a f-{?ݫ6K L\>mޱH\Ȭ5%qh3TPs0E &Hd-9Nՙ( VEw\i85OPWڝsl]uʾ'Ӈ3y[anj;o lBmhXU.HSR S̩F"9yS[ss".dȌ!j_&%RF IbحR߇T@"(FClmfZp>YU1Lxo."bJ\ ejpsD9*B$G9It6Q8P1΂)S] c$uRt؏bmC@~s>? {߆'xf_t?O+>R c%U('WYXX lak` #zF)J6J&!L~С)s'x2Š6BUCTleeB$Q3VcXzs`DgOE[9ChBUK5Yyb_=7yִq ʱ4wM@5x%kzV2I9qo:vX d%^l;LDo4%֝G#:{z5&R!Aù tl_kT6k Aͨ0nZ~kv'=!zLj832o"NѤ@*KA߳ -h/In/Ԧ%Dgs߽3s)~gXH(s,{wP-|☾ktK <32:͝Wtڽ bOav?yzY8o6J.!< *WVC % y&59o-jDkԲ%ᛦO_@lMGROFt*OO˖<_noYsQpv9\dڽ49(6 qMUa* ru9D4مü,qY%AG!#rZhi<bsSK45 ˔p 8@*})s[0x9f:XgL \q5u#heui* 0ÛGڨC*m^ic@07Ri?70PI5q?{kxšaȸ516E(vcyg6H:bgpCZ oIKUJ"DE%MD4DIM7`Z(!",e-ׯ'e{yig4vS.[6D5ONݽ)1g> #LIzNR3Jsۮ%yFSS%ܬ0ո[RȬxp6t@y5@q+ќ]_o#›zA:`לæBRB&B"e;r#q AUl̿фEBv<-a8926Ls$b=`ţ*YBx6 QAw %K1)Zm?&yeDtGqZzj[JjV@*JM]:j3.[;6G9^{BԓJw1G@+4 .aZ'4N$,2S3&fKgrׅ];X8qwG^dH轰'@@/:aM{YF7u+ paW'tpn@{+rTs¬ӸFL&tuE 0={2fmǂ?bwGvO~zg|sOnFJ#%MG9`2!]u `["@v'iREהN(~cÊmʆDO`ު5b^66ԈW"+Et/ _0*؂unTߡdzRοmd[s!$>!y 4hAEEZ"; `o!f3IS\,h%sM;0W<9 0 1-@]0OdRC$:n'?>mɥ~t:EJxcMj9Sc1bice.ZSW+=eVm#Ԯ!:BL[;H7Gq{`o5"punMt(ã_ьT x;0{=[lp1|['4-')hioޘr2Np?A&q^7choc @&P>[3<ͣ!l k5PXat$Hy*quxcW8eeXnY{]/Ǿ6P1Kj_6~yv-ַK9' ̪Sqr (\(UJL(b+x5ih<ڄ[l7l*/娼P=(G !&tdDH#J,%ݳxo%bX0ǽ\.#7z]IW5bd]*zIV5TAtYk`…|_ 4YE$Ujt|sA %ה3_FEyJEXxpxTvyamu`.8]!ºVXy( e9wPD9{x WїTP4q3{S.}~E+ZI\*+͌$F!avr;@w qnZx"c= &?jXH5ƵҪWb"f%]!;Af+v3ݹC2]ʛFQS DglL^߂{,64MtxO#fZUs}-Qe]6Mmb@$!vmâ{QVb]eF_ǓxRTDuSNxiW@Td>x}}/N(gDׄqH$Tbqy62L')Vceɞ69GC5R l!9E_( so8%ywX2BnN!AI Shˇ'8JI%FgͩMXiZhDsz.=nO[֝Rʙ~⤘x&`Q)-C[ngu!VP] 2m…^d%#ϐa9GXĖ£V0l ;'Y²hELՠ7}@c9ПnG3̨b{s\V_hA*nł1Q%%.t6e3uQfTvU1$*jp_I7w"ͅ1V#,UTs|՗z( Dy+ Am¾ g/q¬ K+ {}Q"WI_2Y"h 7펽=K gY;r<7".Z9IAZf:0-%nd9Cé6]}41\]to׸ }-*L'XnоV{`G@ZJWͻ*b_Gk):8/J8$ , ((15~Z5iHPN+ o+C>E3MÌ̕ʘI\'#fWOPF\mb el#T:/Z,f$b݅3f_a-PӳBpZ+X)TyF<)~kWk s{͇(OnwvZ۝Z!tsgcUTf5d4_~ч-t~!~ˊd»UT?}*FQ#Ri QעADwU_@H/(**C݇H<םtd0}ZNa5AN&Ӎ R[<dB4oƷ8s{4MN{J|E~J c~z. oQqzcf _` iKe< ddxnOG7_>=.9o]}+ʓ4O'͸SMr)Lx6<tܻf9Ҷ3mxz;XZSnNn:oƲs6`8zd_ˍvF5k]u|r~uDJg=5d8:<}9snãtS6~waNĺ:!4ZID64!$rnv r.`yd0t`U L?ptطy;dz+r_}Y߯M_krD@ >cHR~pcn&gw`&_!Wna<tjRUڕ9>hDC]e>XY RNO{9aX4R hyVdsɧVY4}N',l67vȣ\*7]n\(?]x/h߅ 56%yͳ5v22Qb μhE:d vx0 W~]]X=IS -RG/^t ͐ ~r9Ag0dzm9*\ тJ>nyz`Tq BՎ)Բ!>E?hȂh,AP`[:oZd}$ZL:\AԮ6;J{c0">Xi#|Goty^XhѧPf}hgx J$GΣ q/'m\W, טJƇX8<4? |} T1rA8~}77)v56рLmẀČɥ', 2|G KF<5v֙Sܘ{&)tKQZDyJYɲޚ$kEHV(YO{w奇aA|7&i'e}gbj:]jt몌IMCޒuzh% 42u U^pyG;XY}~QߘpP }KOI | \Wʒ,`eqd.yw? 7b @‡!?V0d.)Z^]!'@i:u~4iKф5hґ[=%E!8 t ]EƛTG]W֯(JygОpP}p}i=#[_u:5k< WD_ieZBM׀ cI?VK,Os zfh|J+̦JBXVFTvQaƲ{sY,I~8P ^saH$ޮ-Ӟt"4NUwN)HY3655pE{as%PƴHwOi(Y媖ud.m 9s5.>wo(`4E Y0[vFMz(߇.:%$^=Z|`os w,[)k,: Ilv'C G;v0vsF2\.VN]LG$ە]x7L+ڰrN-]v w=e igepj)W ,~b@Y\ѱ@:cH}OTR #~װ~`D^7_H$uibVn?.?fdm5 qVnF DܝO2g~9_Yq1D&:ܢ4wq46zpֈ~GgT02śt|nzq XxʈUХNwA!WOɣksB 5Y^fWK.RtQ(} AkLxXx5'J_ !% \=H]bm*#I9,(&l3A@2ͯ(czޡ$GcqI"l,U ^—%{R'x^-c7ds"pH!)ĄYO.%E=*jbViՖ%yX\b&/>]<2/4W%kvz2_@3K?Л0ƕ}J D Շ'^ShrJPbZ3Q.GIᑇ %\FP|J )3fl1L%ƛ|ٌ>vZzmۓR}eP3q)"umIAan #dL  #fS~=XLo%t m2.GZ,4j[uf [[ ˲RUTup%<+]0(Ghf w _&V0)fDh;ܔ ,ﲉ,Ȅe,XF22UgR>9TH,:)EZ J9@\k8,Rt}Y- ?zyD]'#XL&jCn'P 8x!aΚqlːK8+Q7$ӡA2 X:"cM &,Vusd/~͘[ O7Uzk]|fB- 3KFNe3;E*7V]V/ީ.Z7pD/1cNG Q 017\-\ѕu^xPPE=ӹT'u<+z֚*[Sy+ȷ38$^LtXGTL Ѷi&fLL]@\X:n Si Sn,+7W0uk兴n}QSUԼhǞaC[rFwPJCCT[Xzo'tj76sjLx?iXcJ'DB3t:KJZ&D@tFv丄CB)P"VdYlnB-ql$G[+#/o"MtQkK(j]qg>B ֶűK2 sEqo |vy [O;TxӨm6ILDU8 c#&[ `Q[X P7;Ijۣs"6F&6X+l;' u`kO,ry!ϦtiJqSK&KxIm/WK깇X 8IQ1:'l0 H@>nהo1br5H%86e@ f_ {|9b R%9c 3]n,7BU;;] ^M+t_/hDHc^Ţ74O6B4X|1Eiw .5{paAE䜓 :# 0Yt]]O|}>ѹwORENR 9JٸrQ7=eEdQ9xb_aqkr._c,kjuDNΏ*:wykA3:b -jtx)+ InE ۊs|IJu8F7noZ{jNTs+j=澕*SQ"СdA"![-}Ug+Ô!=忻KgizBe=('KcƖ_[\Zbw46E`SrG)1^#PP ,Yʾ S9!=Z?z=*:|Ԕ^.!?{;jKlt!^.*Vj5@*76݁cIӺg-B< &GEګio_!n Y|̚8Vʍ`Ŝ_ åj\$MQL]4COy;xߪƮj"ZCbHg RzX9)]4Wn*)#k#|̢rP)EB=fPEn^*}&AY)=WoQ6V줴0W N1j/_Kþ[Žjw޹r!6&=.ۡP aX\atv#[BxR{)2u+݃{}VXKtg{ǣÝ:O.9-e٠)lX.*^D6.0Di R !(%5pMx}2VH~ͼ\|\ \Hq&٤痸r(%D85Xf)2+zJ~!3"jߡ/Hw5e9{b.+4Ӆ]EWu>ؠ/ |H\Z!"YhK.H&\~`ĸP^ e@cR~(.B/>vAdJ*;Ws" TtXY1;{i/q'a"UH+>ґZZՕTF2rޯerSb-(ߺmgcnT%֊n:CL﹈np>y R`ȡF]x:Jgɍ^/dHV?{y7EEj gNiM1BC2]1l8V  ,Z|~oK]`GjčRwc2JnS$w밠)Iusz -K~`/L9+&BsȫCj]S]}ѻ%ה3EgG.4bbzXF"1*5̙]5#4)¤ބ%"Mcv<wnSۛcA] ,O'iv |ڻNF>l4j jF=g\Ql0>s^S541jyB=v=бp<.UK-SrL7g}zؘ;@A<ѷGtj<ΎlneH,gt(ӥsyŚzqəI|ֈi2W&/&ؓe,!lK9R ܥǎdd4M.0ɺp`J 8sqZհz<X:㋘2~?M1ӱ[mXj t8AZ;J]-Q4Vg96a) V!_H`~JI΢.G/M5PH;pˊ6!>8'SL53[tl5j9ku @c"V-O^7'*kk>r@3͠ޙ%XaN 1bnM͕p=iNuD 01 S+rqqK>Xoܧ)pAJSpLt{ G!%񪓼!fƘ8NtTX{M ~-V~HST$n$h۷dYKV&p3y=e k{p]>an_t)%`-jA$]iŖ]ܘRMg} )nH`6kH䚕I75ouN=3d#}WXUl8.Sf}Ԡxd)g>ޢvu:)3F_&f[LlqWƸmߕgx 'J,߯aeYIJ;x>0eqytDzh6?frw0o1JO%13% s'+N!uQA^ J4e)!Ϫ_v"84m~5yGeS@2*3+휏.Wx !N<5aqFwĹ}j"0_/&k&}O^\ y!iO;%))#7lU2qSP ^ lZ$j"I}JєIwָ{SHm|9OPYa>y'wNthԓ@aAi Z׈)Çt9b9{ܰ]&n_d>Jg{GG2%>jpR6XXSWo-CݴϮhelKQŞ:&CIOWE4N;d."#PV<Ə5CX͕&w𯐌ĺ,;FJ2أ`(oC5g>c9,*2(*P2n3ʒCG9@6*w~LDx ae(8ƫ%% m'໎{/r =m^.Ʋic79{ۨbyh'l^&;fxLMkŅ\T?sly<.`ۅ\[J 9Hm^eO^+n{khw Ke!uɶNUCU}OvB^(Z~\g ,ԍ#?v,?w.R(V@,K|=Ȳq,Ԋ UݓPrhs G A!p/ c:Beh؈')&J;B ~A*!>_q rÎ9ygyvXKRL&NZMmq7so&e-3sj>zu <@w6kkm Fybw,xbt3\wH_csNUpJ}Y8}wxbgy}OG?OgFvQG=@a(vffodԟ t ANL)6#nfx,$M -iY 퓩} DnQ'/'wm~ˌM8EBC-2٤8'.VEUǼMG(Qc_~r^;fT€!}s{{1)Lu11~z-BX*ٖjXEԷB4OwqMaIznc{l`i%<>E2,I-r~!*oͷz(ep8C0 lVuKyj v$NPNGo.ۜ)22,H}·6K{8 N e,k9ǥװѤux3KujG))R>2ZY Vċlbc[d>glJe2IxۡQtf1 !V#tY>_.SQ# 2 $Ts؋Ūyb9_cUTp闣Kk#G{ĺZBX y$(|"9~j>uq:ʉ>Rk ୎wYr+uiJtւN"]VjS<$q5tOMTUO#P@>8:<(T~d7 ^+<;Uih<@A"Dkp+\ǏJEՆq~=~ctt(䕛YG>h[5*zORg7a9Q0f61Z`['W1M|۝ZZgPZ|3X;@5XtZԖSO:3s.<˸EXB55HC"Göt]8V_1 O ;<~cYFF8# =[c`+Ew@"9d >cjF~ǁ tp<$wBeFUS` jsDZ_Bxj(c,i^sxPnƯ*:[\PRYSV}ѱj[*5OBʮ:s-e)̩][^J(\17Tȶl ΂HW;cJj@M{&٨܄00#r)ˣq93hToޜB>IsteZC"_ou1nK2"=@솖lE: '&к3y:feW>^ijD?q(zT{^7s:ހE’!+VX0%yf,45(FW!+Nhz9"Q̅e&$qdҽ}R j8j- &E&A[}/촾{/G R]V5tml?|K,[EWwrŨ R3h: 8Y_M'=N#~\|onx\v3۩!6Sh;Op:;, J0RsRU<:TN4ktNf{EMs"Y^x#}I~h>ԚTpW(n|ԖRd#G[~7]tET-τ'q{QN|#ج,aMSڑPUX@)xt L|9`uV>D+3 F5~@urh?8H2t 8eY#ހE[SCQC*9>8PAI\FTKI Gi'O  D]a !|t~3(p[L eZ\*e:]Es[ۉ e[9mo)qN#zu Qt-#6zQ~a3I#WpPEHR%z~U*z >X~ݕe ObdPNU"kw8o̓I%ul ?VRw;[DNĀ\jjvn;%Ӧ5;7"f#{sUVK!r4Nũ. #ek;A{5O +"uŚLXΧ7O7K)j)P/˨@mzuN 鏚hUI8@SCDQU2] |G&k&{^dJ=^䢣?J/Rkm^΂P 1zfa*0lY%U#M޹m<ӱ1x́H5|1;ē3hԊ9')%N(%1 b'Vt*ڝeHq%f9>WitP哭ߝ?aXn1u:<hj`cfQzI2vBꫫ&U&쌵3g"8;f͡V|q>PS?9E;_~?dJ$kALE-Y'~I3p_p2nN~?8~iwv;Į։yK Wp?J(փDTW'TDo/#iM %4}, ЯϮl1Ġiy84/ݫ=Lm_EV1寭͟;GۛeYu,Ǩw9ED@+rwG[%OZ{Dm`A.2̙Qğ3Mr̴ &RӒ_)t6; >ht*~ 8=?kE/wVXFn;h Yc5UfWDG~ɇK#`!@j`K#AѓYͤNf[SAKw˲Ā{`_ @TS?JUK=n:#%Eg@ G~.t7/p)z2WC#ܐ#@U˔_>ϲg n - Kl4]@aT={9ZxR&ƣJ 6LЊ|c\]ڊL:7xn2o^wНD;3PCpx;>0Ȳn`a6#[4N}UdNxڡ |+NIP QQ_id);Hp4CdF26&H*#̚*2jCQc. a? d1d6#S{#}A$q1m4iz@X*h7k4z{3-4h.4k嵯({:|>-~8U\^o'=mP X,+CZ4_Mѫm6iTo1w:w֗ Zn|M*GB|}G>HX z|8%̡!z*9/b $tڝ aZ"&8%ehCUf3] iRFkW{vAD9|â?7}av;(R8y\Mk>"A0SN]Vw]w݋|4{YY$' K{ V*S[U'JnKT s:uHFIM, $B2hl7JIFh*M2G˼Eu?~[`M&mvI~qCb!UɗvjÃ1 X銻/< 0_6j"W8hZBbߺ*uZFè!@MhZ&`}_elֿӾ:~,xՃ;Y LprUFaln;nqlS"XOG0N?:F(BU>e;Bpb51 )@pDvBqV #zi\p8.jၘpunL)aa: F;L-8 :bip 9Yð=û${?9+Z?<~ޣ}8tc 8GPl^W)\"gGi4Њ{j/ /(n ExPhF(b;MWCWF3ܯ` XIv #bvx9ivG[ݽv뤵ӁW *ks'_RU`ETlsFZ)3r,;,.9j}(G,mu֑9S,B.e Zm3N'΂d֚cб'Uc}'JӉ4~*g&O?s᣿ܺki}Q;\ ',0'ukto[1޻oV07j {-uW ĺ7`*!7'?Tib7_EӃLʀֹCf6(:6Yp)*?) ՟.8t';'4mt(vZY:遪+R-|7/lճ5$Pv-]75pë3`bxMQ3L@l1Ĕ)pH+u9*~R#xү>eC \+ʩy"A,]G3-ԂWj9RzTk([uZh(2 b1:Mޥt&Q>c!G-1N P%?7=EOMs%#LA_k2FܲqtmIF!|\|z *ѤRޏSʍ ë ܝ㮊uE/ASF>Z\VEqǻ& 0@a,gf:p˪B!&B4!0#dX,侙M'fm F@жҠ-cvHW:g8=~Nz^czv%V*gc7OadI5)Q&~ kRʡ;xsGsx8ΓNҫ֗1V .T?|*}tiNP" )l`MVӛ/gWwrs}aKd^}J S=-D rHaR9jnu՗W&9gk 9?pZ}ӂۜqs2^yGNY@MW!;<䳳]e9 PkaQKZ*$I&*hVABu#aP_@ͣ7R6ВU%խ|f"`Y^(0sL-@|[?&J(Jik ҼrN#;U)E1b@A^)TnGp:(u/ IAe4̅?m:jB=~b)DT~v%&Z]E졇^$QEN_uT?e&n`M]6Q,jW^ǯ q|*z^ZiO fʑBqx[rzm2V$VQH-ebD;aFлExvJk/n{ !#W[ %G*#2'.Ek+)h[&&'-QԵO_ewoF@ϧPJ5iUsPmZIIc{%F/fo%n=E`0`0p*+fXwF]$=-9/~vd/SRCl)_gk Ae|gl@KAVaro5Y5SiIegB!RF$$#0<"M v] 7Mr!QK =_ǯ>-rıFìΜ zӒZB<,j͙M0~ܵN KVݵ,*aG,5:4v&lwMUB !AdZF20 $l"i mbHb.7=|WQ_$l WYoPrqL#_pD}K(lQdrHWu|0z=5r= umL1r͑D,ClI/<CcE@:80",g!d$1 Yy i l嶊0Htgֺk]tgyBZřL7?qm81E,Zd(eɶTk?i*n ̃ ď.p8]D ɺz5m>P 䧿:sӑGp$n,8f;Vłz} OL R-ȴq!uB;K!8I5iJB,pƑÊ6j/+c =wVvAy3=[KȯuSUx8uᨾf0B@jzްob! t62NĶA*ȡƳYV=bwŰW,i \3aGڇi9йQm ]!qvϝs5t ,lr 'ozs-:1 Fsk#sBLD 1)>}Ef4{r.}#4=^cuzL;Ɔa[/,qΒk^ !q1"ֱ PfՎ"$Ce^1_%n*1PF?`Dq>kPzv}cJ^&[mXه6WC#K%Qq5=+ Gh*O䱽3[;և>\%:ֱt#|q@ į;z,Y+Yw>K3Kf:r:5\"pV)7Oޚgn{5 :)$F!ГKy;$wW2^݊J GCeh Qʸ+!>VVsz}xptP'&A1Mԟ^5G;N}vDjU;o؈\NSlL(\h(^Anr~£@׽MÌ( HeW8 ̯uNHCG+'"\R^"^bwj,8|t96 "0\f,Ъ6,+\; "aaF}~pPe. \R$dI񈜦ZW>Q'8}S0vllFUIdo|3Z8z>C8s`TT9b!do2 Ia8_@!JVQva -Ar4l3~38M^,p{pv,QV-uCbƮ O dL6x au?wu|c5$~F$Oe䟺$N.nh*scu.U^ƫ7s;{ I*ᴵc!gY~jj:5ЀЛ__R`ؖ[]@ӅRԯ[ۋUn.^^$a<_UIFDZxj Gyu4Fj.D`L"8{#!}Eb` W~*/}XʲeԵtAYQ6js4YLK:k@MYwʯDXGW'*vUCîFa\0"К?o#du+t ٛA91r!հ@jKS}t9fE恆ǶcZ`EʩF:I7=qךVl@x\Tz^/u^:-i/E1KDK_bl>6Q}`8"z7Uꗻeˌb4c&kR!s3OU{kXb(/생U}Y%C<x?a[7: ڢ c>ޚf١ÌcLG0 F@SDL=N| \z8L@>pcыW\Q4 JhCE$wh:#H Qy'd j;c(O3L}"j&1`YHn`Q>c5u铭+ 9vO.{Eu)[YM勞;.|Lޅc*M7d#9M[r|-Kٖ(5W*.}lU6;yP .2)Ʌ^S?O l j}^-m*4;=2iO;TyHEli2tv|sGPkul4x,0`Gm&¦jP? Tۀ1d$_2{J{N$!,Q9ƿe B?ULqtN7$\&'y3^jn<9T*pK2y#?a|>u̞/k<;HC> պnh_Fi^C%ĕJ ;% wO 2P)_m|Wy(Dpoe<_K?$PaatQʃb*'eؽD.zBgpoX\2].WlQv'K#C,Zd-XfyC[q]syZ3)-k͓[f/s-j~ VD t6.#%%!fy)QD9)IRE]sA &XSͩs'K> J ʀ'gK|ͫ3:e>X'W*hgQ_fvtq.$jöbK*J6SQbejaeV?)~(~_0e˙_S+Uކ kNB xAPa`|FM:2xuEI "j]*={w5 ]QO#K`> 9؊Z=>p`ܣtp d i$oa˙\F#ݏ #xiЦ)WN"CJ@ !`~݆ 5e;jlrZtx22 A>Ѳ%&41d>83,o'zNhhد{z~vzzbpzCTJdWdՃj&2*0\B*M/-(f'6TaL{$>cDm|ԓ"ՙOSǠ[a ?IU`g͋Ctoyɀ7FCq;?xNA$`S!pY; 9kW3H{D<Ʃ(_ E+żePʽeѥr|EQ_gWbPbN$Y]h!MCYOp޽RsTS<18m&(2(:*f'w.!p0;s{'|7[1-aVS&pn9Ωl/db|ѫiG,ٵz`es5TI_5t^[)*E,=Xk*Keu ,q9 ?A̖小{9u)V,OfҝȲ͟0 l'`c$"^CcQXk o WB_.CzH¼ûtgb#߿~最 ]kj\ QD $LJV&&$Q J:X&h"Ć|ϻ/9,X=xeIYE;."CGhN#&TFb)":9JWH63%CKY*zTJ[^eYjVd"#X#w OHh W6ݔ$?%x{󪉅{.+DADZEꞫO)ؒ%S8!Tph!TwP|ueV2+w=WR޽g~."1_#ϩepb A8H ƟC'$L P'D#<Z= 2a8L<8d&j}kGs+X&Z? dn.vy͖ b.yrUm`Lhb1됇)xfyz&N" i!$6>!H8&,TU;hT\nbf_.%oMvˊ2Ov[γi)0?st[s NG,8#<qy‹u>+t+*62)'i)&9 M(:g4x| @gGSMJ!#ޣCKBxKOY_,be::s.#MXFxX?xI7>Ϧ%BW'cƞ׷tT^$rLN=]hGa6ize֚W+I.= N aS 6I35ij%3ESe@/7A-Qaje y5zogm\~.F+#A/T˽~JUaU]t]8Qu3V&Lb$]zF8tzBtNJh_*[ZnL2)4z D(a Eq*G/~B/{_YbD|h>>Otۿ:A)quU/$:*KeSH/qa㲽7Ġ*TnoإBǍT{nAylFQr~Pt"6ci57ŽNKVVsFރ"G=:%"* 7l-(҇ۻwMv&KilUɃ()P!M< lpVn88aCD\P}]Xva-<>ʊ lc|>(64ZU-k?YKp*Ioۙ%]ciB;:F#ـF 򂆃7o2UMk'a8c`oC6M-kib%,[~-]c/Lzq4^rvxMoi_^bZvX[9MAnCCZhѿ:u@voZpx[Z*i n#aCC<(_|Sh~5p.}5Kf?ݚ̓# ?m.b uryr`> F4Lu`}#DT3DQL(QB3 )X4vd%% U%E4>&ytf+U3lqzxŽ4/(8fU%ځ8pi6 /K [t(%kYoh2m.n${,'z"8tbo;mJ1&l>27L.AՌd!m[L0MD($b.EmEjPC,4TV1cZ"8?91^-)a쾓`:s64Y=wP5dX)YbQώ3'yCsgv~nmskA ~.& 9Sh޺YmvoP^J}6Hq ފěn".,~t0LIcgnA{t&0GF]ܬO ' B( ,T;ZC륢b*\AsWT=^yaY9/^v0t4:lW*N7 Z{V7am؋m6\emCx<ыlt,6ޒ.AQ8յ(N>- +G 8ZRyQ˒~8;4 iRcۅ; 3 -ݏrQ;  !h5KU"K)?-..xto#Qqhӟ_F5P+ 2+{f9Xy8t 91hd@Vd,v(Hb\t3{FC&~A ak,-Zmm_LDB,@ckr eQ,H\!([m98QO,F\ViEIu*\T\НJ$rdH$h|J7lZi<$ߤ?=[˴SE6HDz`2ː)edepS}E4T2lZ3IjF} N|n~xSvl>q NVJkā!Lq SB?\p!l{&:|fi|Gt;Gx۲;?E;n4z3K[vV|RB` Ap-guQܩk^tz/?sʽQ]?Ght1νC8qN/"Tg=R~];-V*kQyr2[1΃X^M&z h(h6lcl$6fJfK%D)ڽ Y9hBY (KobJ|<\9ݐ\-2fh ã8D˖%Z`1x.]Hg+퐌ƺs~|LwJ;AxM{:#LK93:A,aǝXLNX]Pk(Fr:gE9 b\j2ܢ#뛡j9>=ʝc-#)&ҳT! hFuXݚ* b>]n>^O޷Ge8\!NBYwtW#էͳ{|#߹UG8XnGaO>пGeuf*2G {n*f"k?->k 7#ͧ1gr|2Hǚ 93kJ 9Z@U~]}Yy=%'vLk9k :V1g~\<Ī{O;_+*df\kO(K_>:zGePp, Zz 2ʰ{=Lz) &M BXui. -Ů'@Em[~=xsjnh%U+lT7 8~ $(tŠ80>0l8  ^4apKN/8(bIˋIb\&"XE,HZuɎX䟥3x行0kWiΩ8d![ٔQ4\L%Cg߃|mms[\/6g _;㳎2OM{^/akX~r忠,}r?(6S4|g}wOwHԚA{lDTPs`Rdd % u5]J=!|4h6Mn4QZ/GQܒ>_ɬ3/==RJ2ag[WΚFLS&o,Pڲxk'Iz[rkZ Qp==*_L~"J2cKQ" 5E&'jkD>R‡V]ox bאA꒯!v,;p-ljϯ'E*Gȥ]fMzY wDHC(::Ed8ae aElI6"uƣSoZU!f<4&ў%o4lrGEwǜhӡב(K/jy1/,g_~.3@B'9\m9cfҪ?s*QAyo__"umQZH J9bQ|5oSO“9zh0 !,Qe$5q.Ӱf\ <{V_3VGMiږ ԃ~WJi=ޚ 3!];; +AZUji[Z,Z6Piy6<6$<0q O_>\Q]˙B<ܳ8-Kj|VLgѶ8}vVx9*m3)_j1qyNA69uu.P?7.P3>~&>ʫ]Aga Ԝ62.oU{vCQc}ilR$v>Pe VKNVfOsms HqWE%(x x׬ʅlcN9;3/*)ZU[UѮ3<0L˺Ԓ:b%G8rLl 8eع٬H3m~2fbqp pEe0-6dHVB݃(V"ajzaZpdlha_\ %M:ms0-;;(M p"W 1# Ħh*bRb4h0hi~Wtnocq0ti4׊-k:^\K0x![$t9$6D֍V Ei1xdՀ/ɣ.Eh?3%gieu2 8!3ۥ|Htuǟ~pōΦ`Up;L|@]/| P])UmQ( G$"Tm\&K,T}\NYI{"9kwyUu\T5t[L{s_̜wRÝuҖ! FT1a<ϚrW uQM?;6!a;\El;^Sy0[Q=n5Zoj|&oX9WGۺ~J!] Ka@P3~ֵX82{an]Y~iel Yӡ\;=-c*$|Y4H>== gޚ'_g' UX[~QXjhqpC`5 Ya߆SyV(~體A8^2e]e'.Y'賔 Co77&.1LIa hqTZQni+Yճa99KŘ- &b s= [ZNPwm*G3u~tzq`,y ZLnCǃ~$ލۦ&eDQ^lȨ&AءTOj_~j/""'VSIuϞE^+Q_ m\E, Q $K  &&" ; ,$-=_lys`.•D>Tb=uh+[߹ ?J; l+oh*m UxqN2a'Lg/t{ϟbÇF5.7 ˗5 Kp[lNZť|Ɵ Vڱ5ee+{+<I:iGjzHXi\ԁPa|6w'3heW,TdnTW]~#`X7Cs0'hzua6y1<ɃrIʼn]vrO~3]Qk+,!O1go8: Fa@g4k+>}{<|mw; ubjlY^D"!6<.$/-AN$-sOEOߔ5נ`k&iꉢ LLa et e* *?Épf*lC's3EHRXLAyE@ךIWk 7XOt.] E{F( ̐Ff.u-:ו3% #e6 niXr橲Մ)5[}k-2!]1sd -Gt&GX݉|$3c*dEo s ! OX(kMgIz枸_S9 w% ^~+d 6(3t#{5n7|e#|7y$AY.wje_PFOuA VǫwEi<IBɖ7(1Gu]׶Y%M| z LJ_cA_$bacH~6 慵 Zlo>1E7oZ2CѾ|S+wf#eN9+whڬqn)L wL"&n‘pͫh\cݲWIѝpч^)MNo4kt,<:Cܭiti,,[`63/*%FNxT­hw+ğYw:[)֔G90bjr'v9 Y; "ny_]*w+5V)5ԚW2Z0t)xr%9nA0zV#fS~rASE<ɹ'b>0%E:J^d%Ɩ&:RQ74' 6w(/0h|y_ދVAkG\x;Ѱ,/qE-B(L`IUI-N7F;gw+ &m3r;s#RNZr#:ж{|dX Y mh<څZhx)5{6~mowۭSF"s=Fb1Umts@d6 ʼn. _-X Z\P15$`\z,s Q$/wWt[D`hd_˻K  PI #ʦܠ}tH*CQ'[8װ>GFjS02=bKE4mKGG66QH`O#!a>K)gp8o:)cny2IfPb&eqD}X*o~4j$l|&` :~{ }=D{נ*XԕfA+-UpgQ*BRm]OA`#45;I/ *2Ľ/Jr2놗OrsiE5LA$[pz'gFg}3o6&f,%\aH˓%-R L$䊭k C %Qq]2u3?yBWn/eAbF)ª*SK=C8P$'$k5p [0h1F 3xzs*~&¤geK$}#s'E:W.S:8ٺFգA5&wB!YGq,Y늼`0K3Gb*+OXXK\Ÿx?|Ud`m1rхaK"UlSy1_Dc?c4:`n;ڝ2~2z!}>D3|?WR{J_v*<`(KiV@!A0F0cR|D.8nb|]4Pp,Uɑb8dģZ,P#3`ؚ<@J)˲Fi]KxẬ&5,|!Jo|ĆpL\&c9ZTH]Z\@3zj+%>Α]$9MuH (FIpnlhn2s/gQ {,&Lf$Ţf#G[za-%},xERũG*!~h:#yXqupfXT_Gz(C\WAkZ b[2lχUY΂k`a {e u's oJanjRY ))Hi5 gCH9~߆I:eqʌQN7H3[*6'[`$ym|FXh˨ݖagUqw/OԌ?Vy[~A1&랂C|#@ INOOPė_o! f܃!paqH+AF!RlK,Rf"+s`a^&,9'Vnf@BGR ݮj0Qf>0](Pd#f6"ޘai02 vvd2W7,{UOׂ8[KUFjGu四8ZF}ǒ{LO[&Je7^QDj^O+D2ז/Μ#~9E?uc-s)Gd+wOi);5]"WjS*oiB0%%MGeWx[YJ8e`Y҉ɴj0Z ɲ:2cpTͤgoYϪzpk!3NKdFt ?QyΓpS9(XHi|'BWH ARuCi~J7m㧗F*w-+Xvo$as W 6=T2?2vqZҩ(+>԰٨qn#>{'~pNmsc#_[SH/#tW,Xz襫 ):M됱Rǂ>gKcn [)C Ymp22^b? X; H]%4UQ1ŲCaƗ\NO"IfXe_oq}N#m. N:~k!\ͥi&/`a4GetWp+ןո?6F}ZL~L Jqf̀Bx4!%X0&7r Wh yFl@KGmA| *\F=te뫋WP(T `o (,©f]GlR3G!8eR8ˆ.LhvfIyl*. En|ero{Zf,խ JAzLTUgvQ6kM%FE 7m8-Fyğ~щDJ!#+g#~x%Iń*+g+9AǖMu:^p{ ԱY i^1N#^zN( OLL>8-1,AR#V_?|}uQtHtE ?hh.R{;]4qÇX* kD?7诇us}#gwHu]VG2E/vb {5̑*x  R "AaS2tkak᫤r!cM%JQ'lյj99^8|0mO'S-b5@z&STe cepJ%+Ejz z}tqtqw\Ng% ALJI$V7:=#Ղ EH!]f:M|Z[Zʏ)Nd뱳Ss~gW00 hh8:\߄]:"f9BG$BؠX|av" =Y&o&CLMy!jǨϸ8~~V;}$P w}>F^e~:zpe6N +-Zx=DP=l4?47C!;@ȓTŦ!j !K֢N?Ƴ& 6^m:zǸ$;5N5#~ ˛1ۜCpNxFTL4uKڔ~VJ7 p$m/Vl '^➟\o: cfOX:H"-3h^5*ҔXHep8!K B}%Tf|FvujE :~pل_4,k/ʀ@awd>Gu8yrG=J?q٧+tz8tˤ7K-=IX3HzC I%T/@>㝎.<*jE #V7D, )=G.)'R`t֚Uwi^]9&Z.C}qfCAC:HHgGdnCf H@۪ ªwb[zW` 9tOw4ܿ 1k'^?JA_?]oͮ_XīiLS;H>ЌɀփCwvx-yk>OH8:8%@h]Nڴ͋o=uE8V$1^ˡNA!$s) dlF͉|-ަZ]$:nXB ~1QDFXL{JS~TrFW %o:"2UCg:$Z\.3˄*~f!0%gCfg.| =7ӘX@vE&+f^?o,lBqO0[XO3frAM$@##:\$wTD`4߫ѣeXp|gvA_fZgOl9(Qe/0~nRۆOm2Т2KWYg󄁲0"`X\Mk CD7XȂH#2 SnXSE&gL s͙7rkFL0hlԋ'L+b[KgW}*&)0{G7M9a'㒎Bϟ;2lH_m^ b&kx clQJ$@pa` 0zC5F1o*V2[ƒcGރ?Fe<Yw^731HӐOG-R&n]|ihZ^͊f&6kV U8  fs9~/"WHL~gq4!<,٧_ h9M.fb'RsV3D^Yèku'45ܾ._Ț2bZCbipF2 3kBi2F =#Tv <]MGcșeQ"VFZJjkޓ6Vڢh2jhj9N 0DM哺4 nc=R̒$L99ތcL` Vln3Үm3WCl:a\Bx VftG0bķ2FUL|ۇ)j D:G-hzƛ7b̹S+eQGTe͌*%Vg5B!2@j`.@0S$'F7xGWl׉58.a^#zXelVUYfNY{ƃ(I0*]]K 8L9ͭLk>4 #U(72vȻwT *.|T3柟)G*]u6en9ݰբ[͚{iUkw–`E3cj߾i*yg:5pqln2U` Q=0lgdlRBDn䒷ỦgmU| ]o3_/^S3(X P,Vz%?x;/e@ '.RK~(< >rDR0@+0 ޝq_FsFT% {(Bp5܂8QľeYԸ_AbM73׬jn@f:~YeA0 ן)/0ck=FjLV*\ŒS.tB.5y!nh`*:JSij)lc%Œ3O $U'ԗ*"@U4dǰYEy9EV$ KZ Rt4FZ%tr"1dyo__Oumw:F![= ^51ʗR^_+#ڴ5 JY R{5]63i_ΜƬW $H)!?Ui5ӏ{pփZƻwVV4bGLh׃G7ώZMkࡶᨏqAHs19=%\vk1ܘB7 /2ǰ29W=%5}ti8C2FlAF^]hROߣZ z[Zclj?AXMݙ~oo9ߋ 6FxQ:b;SoE5e!0Ot׶`uGD#듟{hXpRV>e'i| XZUF $2ek=`Tѧ1Oݡ!Tv1<0٦KZGLo䦠hB>oDtJZ (t CJ4l_z1k^zWѠ(p ҏC >~ 𸜱gbM=a_3⾝̄ r2S s; uOeIz74.Q;Q֓gaECl%=)6f*N\ƅK*1ͼ2_S.LC49 0#_h|"Q WIE1h06 A}(He$dzFz{|bڹTk,O0< ,x;vbÙRud`B:.Rj;8d-7XaV*.TÌ/b !֛շ*_LsnHPc9RQ0E N`3l%4Z3}1RRLgݎ7yhDyJ2Wf9GƊk,.%&k"gHGҳy"˻\ kJiNY ܒ>s @`[ F9aUnRY in+)ȧPG[c`kob.FP&U<֟Z%ԏx.lz˽ '7lԂr!"PG47F;FuQ:-=N%uW%D<8JT}:Z e`j5mpJODS+yԞA/3"p 1tRH9Rh٤99م-섟M de'~` 8N$;JY.DUg)|fz,v)#}j'A䙯ͪ!%-v'n6p(HƳі&?068#u x<]uOG.H3U}6uS[5StY irLm"*HX`Y-ʴɌBHFHi勝Bcލvx*Bfzƴ9|cqdHX i)8Pz}n1ӧ(kՂk_˞- )4Z<j$\j+P Y|<'FŨS#4{B*p|q6WM )9m.Vso9ST/@;~1lܯ$61VȀ&xW%'F+ؙDV)u7"k*J2srp2zbZdXNjmT #~n[YVp3vt8UOl"*,DQ_I$m`exM vB<C .-%ݩ;ed֝eҦ>=S{NV-T]%w~Ti>SAMHoqCe߽%Eca*\Z<' !Z Fc"-M"3j41*80ly>Cw&ʒ`-O@]CqQ~$q ]>BD)4Z-Q[Kk@5ypyjxB aGmO[B3 (Kѕ<P>3H/[/L4|Iu>ΉѣM16xGh[TQ6B`JU¬D6/2 (T T @PAH伬)װ#m(d y9mWYhK/ yi_fQܱuRmc>`Y,E&!`q*%Is#-03H1D ٴ-Z*>₏R $绍dz;EPw>>yz^7B#s<XާizxHKxǦ)ip. =bd*a c+p. ڽv [*w:M1Ŧ)Rr *)3aOgϑ0+wx:3 F4z@mIXijIOoGTM̨rÕ2W+W?cl=؞y>cUnGhY mA6aǃǰ4ousfw[ Pͣeс5U1ha.c8$=T_a? cٜ|.H%N 0\(f@d6H?{޶$ wv iRs-9\FDB$%E?]շ @Pv2ɮEtWWWߪ  r83'iD=+ѿz?E]~zYJ_Y4*ITy\TvNf v2#?K`l ϋ%LX%M\2T!HIp}G%R3)]>Bh &pyNP2fbM(1Gki\M|l~p.I&Ʉ'ǵ P S^/k `dl¿֓6`418Ic:ڟgw$x"s^ {J !Q]9w F7Cvq'3q뿟HjiÎUNo{qNDBfOtd̋_I49 vXGAhG ᆆG0πK! 􌻴|jӵ4bN۰*xepb/bnhx~NA +6^MG I{|pǤ5i{55 Do>s=kH[y{0AdNkwrН_b*fmE圡h(JG Ù%w,/\ 0&i|LHHLL݌phR9Ȧ{ Jo9MʛkIOOa{p*%zL΄"tHS˞S)EGg2uZ&ƭ(h4 NEQt4nDS-֧OPb}=.\` xEkO-#"B(Ҿ(>}큁 ,KV@!ON;$d:.AOwrHt}k2 &%&&a6%l 6'b>t\8 ZngY; uY@,;!H,0 ,#jD*{+ݼeM Rk!V>2 !|AklʌݥA"*hU1w)hC%^ƶyE\#]d b5Hwý?0ѹ|Cuu`i_y+_PuCfX6s+穘wh,w_uA;Xu.BS L Z=%K =Z1- ^f3x᛻rSt-~̳BOp .ENÓ`?^lWQg=zT=ƽ:*lQ#.2z=|f/(/Z|\-?fsC 5cQQQq'7WJNHZ`%"zك<x 2 @|*0wC%.: #|EAy'Y鴬niUj ,wdὫvk6Vj1ZAinax⪿YP"XQVYfd|Cf̍f%lŶC(__$wL~miZz )#o(>HJ-Aʝߴ3ǫ@a썀؍H/],gt3AgqΤW^u/ٙ?QR@`AUK)n,qSVD^v=A:_hÅU̝k2(kͽ -i}K#43u`tC-R!ʷ~o9pF?\8qb#a/-#p.6(iH  C,HyB(̮Ĩ˜Ga\\ʈ&AƊQc~M_S:ʋ$a9݊`qLaՔ]!v XPIn!HY^4EtkuF{Vx#6vt<ޣd9=+6y+~DCmXFc"`A-|& h*Gԡ Rry y2v2~7uRQg@JRqDG!+eEɮE^TB dk.oW4@w:&3:UJ+zn*O<Nt'twxR!zYRsp2Ϙ)WYӱ38<[yV k\@2 ^h)n H }QFu5*=;LEE!qb&8U<|^׬pfG(Vmºb2tpɤI1OʟfXtv)+jT)+\ Cי@Ux>]QdtWup6wFL @hfVl\ƻi7ӊCϏRiL3I.#OTZmmOh,.VBEA_^F(Bn>IU:hʂ42b3ڕ%³ sE+@5Q~rW"3Jp {\sr:},?k ݹcPUY&^\ҧ!{rCqkn`G. $C=|`4rxx) Ec=`1T.CHr;Pz|pK۹[(5v"'UH&/VInJM?*/l|>6G;Y(i'SE29n{ivռ`?')wPNBpVqϧpϺ ӻA3\.;O\]]uvIo}r}M!!^?t.<Upw *Sl1e^_Q-*t:e]G䇕&$J _JVLHC}7 C!JA;w,!޶JPP~w51n<=ʨYYpCN?Dܻw[K!HIo 4R?KvV#3[eЄҲT!!'FizƻNΜRYqZf<V%_Zs tr>´x^bXNHɩ\U0ʟcR]~9 -ԁ14,w˵|xlNc!:/E=1Y׺ճX I@|gctYf2i#Εf_O +6 d^/YđwF;nF {X98J%ٛ,^Q5J,nL+IH:Jo.wȪKMybߴ01Lx޴N\eU*h[vvtX rLÿoΩֱFz ý_|T؋qjU*7uQqe J EԼ'Ʀ'ə*(.>'9Ԟݟ7utK{eRܳbX{*j0HbTs[t[hɦT$2Izc>SȭG@L`3X<]vn\hbTxY5o0217g.ƴ%|F-?)w1B0 UpXwM%rt^jy~\xo; (XI`}@Kt^Ǒkr-,S}’D"߽$:\>M;9q(,֢[x^"v,?\T]3YMd`5=]B@Tdii:{l2?W!tGNX5Lu}G{1ˑs_|^II:ZZ|xLƫҷVyZ !;"3~-Hi< uomM)զ`r,JxffÌq WOxq m~Bw3X3ϠN$e[ \Q̵H=kPqv`⪡#c´?lj3#sp2(dY bz4MO`/u^Ŀ'CԶ2ƓOu2=Կ$qr2>}Qp-1cs׊>Llįi}]6%HԆ#L(2P>w\m.v-\)d-<4t `CԒ%a,n$F}s݋$G$ǤɡBډ>cTY 4GxK'8phjGbTׅLx3R~q  S3PC!:{V#> tzl'æg07.)!NBţH*GPp>]l=r?]_s4PyD<ډ0>?zs7:lyK_t0@.Bz%/㯽kف᛽gjxtfZ%600OL0]n腋p p&GdхQg rz,9=+glcζ3dؖmia2lKĶ %b[ l>1.72y#(`r!ܞ/w[n4cK$#YQ_I=Bm;PCk( PAmP&d˨)?VcYz%jz,z0KONSgGEg(EX+-1qJfvXvT{s3 6!`lAJ 8< $dIΊt! / <)T1e(`uVl1Aܣw>u}u6IA?˰l@dR|kZX\ vuc|c=\B5ϋaݶgp#n/É:?mzϟxmzN7/Ŕ3Ïmosdi(5o=m=#M){B){B){"($.Ϟh]f,E3Dw)p:ZhOSjwqb؏zcPH5,xNNTKTtmk*Ze/mnzVMۊ5o׬k}'#N%oӽTBWkU9l;BOigoVg5UFPVŤcm?JtLB }-~NL\ءH[-5-0JXzjA[dg x]KpIllb~@L$*#s.# šdO> Qy#ZTtԋyƋβ9ϬWڝOXljcWت"dr-!.:Q{Iv;$y^zO4Բ}vB->O>\@ǜ T*9>g1H: xۨ]Ķ#pcy=K@9`/#j 92FwN&\'$+ F6aaѭ}^s:IfV%7@<.t@|Vd4ɯ%zȵFIz2D~Cb{Kp 0#osN<ҷPN  +ΞV8K'0`VB%lScOGX]`QpY1ᮄ%_ ˕Y;5G>v | %^Dql_}L""|:((M_ Dy X)~ ëGp])'ɫQHw݀`U2ZVO}<T׬Xf:bR|e :](a=ZJDP[q_[>EM׽chBu=nʕgC }ca\ @aKXPxцud~d+8=UBxtR~JK"PnFΡOϕ5fHlmyMOU<Ux῁&ݴt ]&99 )R@5fBR]2}f~DpgfRC_EO.i:yD/E:D],{lu1ש:9*}w 3,5KxclZ% A_bZ}H H_aiG8GѧyYN7xeN@ܼ:'=s e I~iBD]\c@$'|֩')O:{qaYJv{~'{(}lb{n0&̕op>.Q\ٯ1^XD7p4HC̈́7 IɌ׮ж?@=\r)?I[г&$[ p!YVVV\ kڑN :(&B* ^ԋ>6W(7C}n/Vڸxݧ*w0ֹp;ܿiAD)EVD; QmڼV .uVd $VT=C@t.(!I#J83J" ^pǍX黈uLM: ͇/5@Nпh>:w{;xz(4卯cVLFNx9}gߤB.{|K!|M1yt D"9;U Z3,+Dij3~?󙑐嘪@b_;nد wm|3ޑEB݇`]wqr9ls ǜ%2X/WǬ++S/l`^Ȱ͊s kfn7"bUV4!fVD&:R\At>K@/P/Y:nQT39/똲BR~]%emxq`~rJ(d빷,V-`sH([E/a6YA'BXG 첟hUr^ftl"O,[]%v 0@mB̚<H;aѣN_ Vo~DҒ FL>m̸Y0QV陴:x_|9E#Z&m]ր'>NNV63+iιaxøkFC. _dS-4AΊ%tRNAe'biZiTx,oyJnCb7쐢;M!dc8IH|@{pQE hS?e xL{9gM=c)2^,; |ps bj[|B0LZ\DC죜1ؚu^=Uq,Wu2iZ[AmqH,A֏ަnA8{q'|w|<Gs~C\O*|Ko9+/לp!y9lbS-h4|6|wM:K`_8T1@9eL4 Z`Ç v]2Im!">,d0\N_(QJR,a 2`tIS!6|Pls5>}b&"E$ "IvOa cK S~Tg9#ap\Frl LY;HxCy.B)$ODeg.v!BTzXEu (D>%BC?n~nI,B(6M Sr wĩ\~Imx6Ӷ'i P+OW*ɑlث)rY \7*%B~\_Jo85ÙAS9p~N%J򈝮Opܡ?KUnjf/h* ą8StAv5b^r&~Ra2LFH]p ^N\!#8 _#N$:$zN3TVQoEzؔU<^Æ#R?zD4Oq=3lc%1C'˰=b鮰 nAoPAFwl* a)辳V=Q3;PW޷7a:}n?}:]L߉CC&K[>uC22 aKNv4x G&ό|{$sp3E^ ,(W\Ԍ1.-s҃\9|VZc/g0vOa?-c Ogr keɪ -2pK/ Er+/sC3lwcu/PVsphtyIAOz.?9*?}?d)۹:0: Ёkٳ-Ϙ♆_pp̙;l(a0TiBh(Fۆu{+׭}]]&WϐQPߖV4|d)(|ɊEN6Ƅխ=e鳰t++%u{`@p81|ɌߕG4my!5xoN^7Y/: GtNHθ=SK]C[!Ywm~07(nx\'e&X:C[prkaok6+zdeU]]1e1K ׫2|"/*=kOnX6o*i7NBS͙GjXrx! QXus!cɲekAG1~ 7>+8%] w`ŧ^."_EBTC3f/:Dɳ5$T&*Rdwз?*WPUQb3Uʤ̚#LV"+qC HnY:Fjؒ>G)N <璒 y-cc%1 8)x[c+sf7bby;]\U6Xzg'. wIH*z"6q}4+5*1B濶. Bdh;Q8`2CCdF/(s/J[$qIC j1XcN"0}Gg$(RC2fa&y֨.ޛ'K+&׹:@[uT#)59sGUjR L=enfJ4>1z,"-3!UџBXƦp6LG8IX^iBp1hb"%`%7lrPSY\R8{t:r#4w%827L!UM+4JhHXⴍL/]E6&wwC_ d0A9(HԥE HP%w[6g[i3zq4ӖU:g'cƻrDM3c,®h\_6bl{_Ncnr<LnO B qӚMi~6[vvkҵ׫AT-C.m-kPJJ=ϖau0ZjY{;tG~K˖~SPW#aT2LFn8C|aՊ!'AfP@c _ar5^% ; K#׺!PV˫ux$f\y̞t/,̙p@|`sww;GtA1xEŽ82/Ѕ;"B6@3z2|HG<*KBɣ-#屨ioea6'K ZOxԒhI0fcAwXfx$4EAеęf"ۭ%[*|-YE'A@RlZ]yJo 9}7}0O䧦cs$8G劵V8!O\GZҀȟ̛NH +/\ g:lJ,1i.r`9 JkDKlLW὎ͤxIls?wR_G[uG>P*mծF S213瘙2UۇU22?V>JB9C5NHpsu/!gl(YFVmn/;[P,=+bjU1*leaUF#s/l&M{tX ܪ+5>SʻԾ^vޏMe?^L x,Y)ih wP+ϋ[0r; |{<W޼_gRuUΛŋ=}Eޜ虑f Yà $~hzěݟG^ QO=v *} T#ýy?7w D|(͗l75E,|.xyy(ޕ8X&V/x,HVZ:-ZEerYO|{t2IܡkuN<ѝxGg 8 7*%a4伬lʋJsQ Se) q$x4h8<^堒.L"Íh5|fPՑe)G:dX;E="様voQVrݞo^?$Xu %-C8`ta60ɧBV]=UI*C(ʷ^OvtzjU1FgGkGkKC _DʍJU|o)1S{=;-5[|Pf@ڞ;ϥ ||; 8;BXxlfZWzb=!Jl5a7,ݣoB&1wsy"{)HA[_Qٖx\.E@x 6nZNUե8׷(ŀ.Ɇ@ni`/ߓCcIߔCc; ؔ5ª-,[BA-'h8q[)lg}F.믃zbU 9aM9g:8Uz<E_k:e;P#1Ё@C׋~="+l(%ǭt)\Ve䋛C< cxp2o an]F9 AڕC3 (gH/t `MYW4oUJ޸*PLr*!ecKZ:zMUxVm:=` / ]b+Qh%4Q!|7|=Ӯ7ShV=Er+T\_]W ͯ/uo},SRglhR8JR*Ki(L'+[̊JaJ:[15&qu &\oU\޼JϗGI:+texp0JuC]Ynq-"4)@T,'Ȯ&[0 "a{i|Mbs U(^(`>hm"r!/Jw;j, 8,T+A(xSeaynC,=T)&I\w*pj ُ`R2# ^5>ќ0AхoTҾ붖lj~3QOlu껥猙g=?`LdlW?E:.MYyRw Wf%J=2$Q+ECHNvk[]`K0ƙe2y]#5L{vfV^ WFѓ*>,w%lоd 3O㦈osL5#36TI~*uF':fW㒺7;vSGewDq5XU[F+Pc;PB> -QF 1jbjODSMԨ+'^XzOԹ:&:>s}[g7{ 3J&/wbi^0ٺKմImd62۟]ǽ-Q4Eu :` dڃw q~ӴGQ5:T(nqH$GQ䊔vez*3F)*r%AMgD 3Z *K+:VӑֹroXwnK}'߫*l6lL .' :p/{onf=Tr_Cԕ%_N[˵i>nxDb?U@].:W!uX'OBP."@B*EkD/uq\#&CFɻ%{EYm}4+|F2T1&DmZ_}(Mmȧ hTt!3Z j ѩeͺ ږ7r_lKH7 Le皱jv/7ٗyǔ)ʠEf2$`끗_mo1.3$RP-(}gxE`2 Wyaxyy{@њ*qn(&ceo2¤Ж5OdݭǪZV),SX=!i׃r> "k.FR~Q*_ ݀L9UqouXLԐO=m&:{Z 1~K!?_?{<=ڲ[ [P뙸GeQ/SRxw 1bɮ'L]Ns~=ΉtS48%ko ]6k!s2ed@q'zK_4BlNi|Cm764:?x Y赞eI`rV| #D.w P-r?pDA,ᆎh#C^"w(FTov$Q{ʋ6DdA8Vz,p`W.=_^#;2@ ĮR00Θ@:(a``N2'vyr嚘*Q_?ZŨ`@<|Kq8 |8)}|-' <>f-HjdH05, UJw NTjfӊ8 ʪ`pGFnX*a e]LG֟m8.,flÒpsGU10J`zٽVͩ/͎y2]?^,:J`ag)O`B4{*Æ3LPӽƘ9Á3TPaլ%]ћGo>/g9p֜#mhuYx)ܮ8%bz :YRtECڛ5縻n,n#v)a xSZ@^i£/] 7ݜ*ڪϛr1b(#FRnL{1Q'4o2OVˤ3;,6%u+/QgvSt.q2;(pEQ2:Rs~%RxHI ?3l>х.+ECSAk.zz';k:_yc6,ʆiGw/VIeW7*\7gz/cɺ`5񩢆,8z6ΰGD=q=vXG|yeli b$pϧ*9.iVpiqu>yZlZÕ|nzN!M['脺;j=a2l@)vG(xmǺշmu!)V=;)5tv))jOT$ډ4 Q7(EW%^za}a&Q[R GEHw;y+)f[K;6novn}9s&Ղt[Ӕ ѵ,|o hKQ"*?(qZ F/DJmG6XC^`4lވIU#+ W &r_Fbh9~{Nua#j:yķ E5JAyh0X{m'߃r_m $YdJl2`4rxxzśnG-qK:CC5PnQK$ʽ f{{-/~i;$ܦ(_pS(%%JkxŹBcڰJΈձ[٫v6Xel=׽yhHf-6nb2GQzŧ2lU3Mt,bi ԱL<\Y\:ڶ˩d}gWX3d2td9;;;+H?g$? Q |xɘs':S5~Hs7l6 IFHt!zڡs!f/ǥC[9}+/Hii{Bw4' !#2[pE!=S~0;0qmj`+2#+GJݞlKTLbQt M4դi\SHBt=vR?}w[#TaIb' E =,9+{^DyKW]J=-BC3I>b,g'@d;2.rIwMF! # x|-{xܺ? kM_Y As nPyy"`9j` QO.=n5kt.|5|0- /l8 X&UЅXZkʙUnKk& ^x:mYhΊ^6>s NBq gDe&Q'#Fx'@g;֕"3hsZ$WQ$8Z6z,ki7o81T6}/.ի,P(gi_$)N{ = ў8 5b'~r"+Æs4cF[Pe"Xs1I_yj>݊_! γ*Dvf KBzvwZh-X CrªQRXYRN42-mXxs*$$6\Rm!R#h<ϰ.'8sJkz7 cw/ۺ1ur}:7;hl& UEK¡n9[U'l' r3Q,n0F _W{){-qG)lb,(q7ATvCP2i&c^!F32YD:2Y:Fܩ;-M(w}t;WńD(13,q:N卖 (1_HYnDvS怉XBth^w*o`pQpm>poS:DG,z %U0akP: dRSp9Wm?rߎ?4[nbB zJ2F|[KpBqbK@Ƹyko ܧIQXGj GUCB8J{[2]~e)}U B~ ޶{@붃bYF|<\]2P~\t2+?`zLo8@>"EGz:1VW"QcWSpv*uE߀4jݴ&]5_ $ l9xS I~]|0-0z%dL9YGzX Pp:S=M̪?G牙` z0JQ?_,Lz,GL)(gO&s7ciJ!YKxPN#i05`nG؄lWξд (tթߺDˑDۄrtvQ/([Vjut|dwXb~?CP#Tft}wcF{t-6ղdMyx\,dw{FB.H@i2 a#WGmR)vpZŶ0x$yVDSl`N`R+gW2Ǥ-X/G5OISyοt-ߋqUuhnvOlNcٿPzOTlatd@s/Q,{:f܎9dd #UaǞ";Af,Aq xoc仲%ٕ%?f? ޔM7e6ݼFVj#(lN(qBAWڙ$$՞6莼TNXfՆJMF%)V<\=Vfy/Q% IҲE@e#/Xp֫DC&t,3Ơ3cKr i1>KД7ՙ)ek?pGflRآMXϦ_]B d^Lٌq9^Q]sW !JQ%*-%M1zٙ}RNoa/NoYONo1;S_5ȣgzxOk (G< =AVp 4sGC|q5IV:#C%fˍR}pNPnG-C9;tvxj, sڞJzMHtæ 2E(7jeFC #POt3'VT}J,"e"d {7mܔݏ -*^SL1$Ԛc<=ٵܵuf:c/eMZ5ҹX>JlӗJ/niEIU٦(:1-QƷk'`W tc69UF'sA38S ȵ9dm;=Oq"3*̄0E@fs(SDVeUyd琷]y*!Tjo&Jv5"ߩo9@eoUqpp.I") Eq.:MOC~5D7Y 3ǐ-5# kH*Ss!rT+DgqG8YpHF6ml 9xtfTZ+ 1{gZ2Y&|Mif )pL3pή‹C4|rvZw/gR\-Mk `&-rXIܐ ͯMaMpTRF',O6 * )E]7hbXmS]@;Oą K*zh.D3y$ τZC/|/x8׊~R lgM?}zhu5 Ie gn7[їK+29OݾX^ kP/9Ip!5`w#.>|eT )q)46grW΂mYڱ=L?m )d$Kܙ3ԠOiNXVlvO.R[HLp]p#Gt{DgR;K-ub7bjcK jΝ**Ҕmvl=}1G-P Is>xdfJ%gg9hWddddrt0(-'If~tӛwy* [vGacB,otV%+*4u;_jG[w7D܁j̔pGZ#s2@(Ȧ1 Lowth r䛢[2 =[1w3jdW b895F] rG*Hxdc4X\Eӹy<[(P|PAM"i(*;k&;pty:#^D>`eByb!,u궺7) Y >mjt5Q MJ%tiW&}wbm]i`R~O42N^e Yp]Yj\߂gYIzV4BnwR+tj7JƓ2=[i.&=5.ujm$ Cd1E(M $TeN=zh kRXGHJd(TDJxwAE֠0w;jdR ȕCi t"%KRZ7x.".d-dd 2e:PlE/%X}kk}d28ǿrN)k?i J 732̣}qgZSG)/I_z}NRӊ?(q/92uF> =&Y S&֖ϨJX͘D=Jh qI: $Ir ]+3䜇tJBR蓱gAtjytMIH4^0Wշ3r=v@bb\m-wBl1Mn|JEVX)! )kϓ+\y6#`d)JҸTd4R"t'8Vcr6xOP#Iu(.O%* ,ѫ\-  ItAGYG=~ =;]F#@iAH0YPm]EX=1O8"禷MfT䈞;ӧE~!r'.5C)pvC-@2HVxv=7|E6[TԘ:G ?婦UǾU^Ӹx g{Dba\]tEMG|lLݟlVu a'-_yT7?uh֜Kw?QvV@HT_3#ݣ)Ӂ tw1t7CA\ˋ}m?+0(I Uu]/V@8X =DUf|j:.bm.Xt2mM%y.Jƀh7_B _柺K~%n/-UR8*O`\(ؔZkU@k(7t81 ;_'@ys4oPH<Ü'cÓy}cv"՝&72tplUR2 W Zwe 7xn͖pb_"3Sc|f$Vf!XW׏ʺL]@4-Ayv8y?\C> Z bN)yWZ?Υ{Z1z XzXrp[|~w,U;Whx `ӧ_|]{`Gy|ɰmwp1JXsk4)%#XϰE?7/W"$wbiw(chfjTlH(^W)g"ls֍@y|m90s껟Q f%%N:wɴ<ZNeB~cOp I74 WOH |(zJ~<Ε>ct|ГM@,ğsݽRBӤ+`"ePgb,wH8߷1-gݹDF Z#J *ԽIiDSݑ]%SLkOJ^ݺSjb& &$u Vu5ji\亲by֊b*I׺'鳺XM>oѴQ܎ru:uaU[!2} 4Gt5ߒjܷa4硺^ihRm/ۻrom=Q'8o@kM&cX4%}:Gm&㉣/_DNEݢdZ!max E?XRZv+/)϶( (.th;=a/Cֈ{Ѯ 43k-窒,_+O++('1`xlw=gPq@շbq U op⡠—Qk`Jm6VAW7A`(WX_rP}$LZDm Rz{N53%8e9n5V`OTk&[xQl> ,J7s1"o_;w_Mg 'Js/ٲrz4^}xмºt I l.G춓S~{8Te(zFsZLz6 .mpN a=4+cT}PgXԭU}to6c{`AMnᥰE@kQ}Ԛz^z{ݽ$zrIRE?t(rγ}>;U7n|6QP/¯0$28i/ggJ9fWh7Q"p4~6cq^fTMy/i\gB F 7F 2VV2`\j˫`)^}cXk5"s. J`Y=MH2䟈3Sg973S"# )Jkj8Ban?#uX.6Ϝ_-zeFBSC|U7M^)W0Lcߔ*zFa'٫&fAU)* 5V\kڊV~!a8$bPޮݣgk?)i>Ww ({dPRf2`.Ҍl)>E@5c]#evaӣc.6-X*(UDdΘT¼SlCrg)NXvqL_ɧ05Z4 ☁az- ܁| MԆ4M}GÎ4팩yr>D^Ɉ`"[\9Kz*O!7+`Xi>;Wղ:q@ps蹃Jvf{=|n"" -5تo77x,Lc (k jGM׿p82mvX?I֪T5"-ʆ] 13iOP-޴f$F? (@)n(]榣b /z} Xmo$NSNbڟ]|tln8ejgw7|iQ$RNyu]~5>0ϷhWOW9>=?GSP7xdf3YFf {C(U ^{2ɍfmpa ܔ9@!Ȥ+_5ɤ,s`1 eEñ:񑘲D.X<_o3blbl҂% _*f mʭHNu 1? j-R4~9J(Z@O&)h)FzMU۹ev?e^k^ p/_\L4jݦ7Yj|608m qqwb4JV51do*qAkzsW58JHoB[LJ17/r>l@UsI`HsGH<9:!8vB,RH T d1#[ؐ#hGe˰wdq$A1L(;j#m ͌ OL/!CH;I3=R_H^jIiq>f_Z%߮ 'TBc F*%EiA/|7?($eѭW\b8|\J|Aѵzbn&RH83̎"]F4d!_} fV6c-Q\ Z_lI\U+$DϷCť]$v:dxK16Dy5D%\l9>ϮJl]ǔ6z 4%6WɽEpvv8~zǡ"T0NotT1;AX|ۻ/>RÆ6"rlk=ӪTKaRLJwGs^)֝ ji> prU0~ȁuk%[eF(CM]cx.)2D_}Fˆ/!*OGmKVp%ioMan8EvDe@&heXYAn!iHę ^Sۢ$f6ZɒlI@AVj#ud 1d@[fĥ"P,nSVny Yy!m;673.;V%FP޺˟*|:a ?G 4bE$U) $&FYǬbA=Xʚ_~njDk@˰2~ /+g,rRO쫣%D)tJ:^$x2L J:~sFaVb~pv6f5~ObZD+:&vw Jd_@yZqp&UD% .@Ÿyl|BC|xqSR؄#w2:PVM>y"dq3c¨[?O~NY7vS|?5#ƒsI fIC/~ThL{ 5-|?QKM ~ UnSE0\.WW3N0.)DNl/γt}mS%h<6cxu-:~Ot+2y½M` }B׵HLzBNi08sNg?wF $'oi%u3pSTc!k[f:ɧ3܇Nt#횧[d2+r6/u=H8ؖQ%!J"q;l[`8Sc)KYlGM[+4Ό myS ZV>bwΩ&CoKر[HsW֤;vjCV? U:rSDvW=2#0#Iaq|U!qHPmduF\aC{=Fƀĭaװ%2kiCӓ'_uvCN/!1km,:f ͕z:Qi5̊v Cb$ʤl *|\1{\T>TX1@g6hbL>(GF+4k@m8G؇Z{0DY fjEy$,aNjCsbv HJ [z+>uNtr7]ƀ|K.Rœ\L܄ CQ0B(oqUgH@b>eb 8E XQh?%`Re;6 l[̠p&+u~9x`di1c;m߻.&\; Qaiztq>G(8Ѳ*k#m0 .Br:+bVK=V~Nb$mz V䄨k&i!y_]=?j۱/a/K'uIR' W2~̒„9#`Dv$|ՈVe|>-:Ϟ]xNe{2;V ̳?(vI.O-M?ǀ_< )eP)ACuRIa@ncIЃiBe !~YW15L0 ^R:z0;e!4e|caǘ,$+g??S$]GqSJɨ?L Mf'՘Q> 3W2CD~E˝lg|;Gۣ^ӟp<-d/EBB%X! p2 ~0* 6Ơnx; %h| }jGi㒤]KB;I=p>4'L.>=}Z&:Ӗ\MtxmWnnE(Kd,b=BVyҀBP4 5%r4Go|c33T\C`ZvOu%;_W!_D'eq7np'' 淯H kۍi/^5^zS:kңcm2 5Y/|4&mpoygqYFԘ\[RSn{MJ(P+Y^ϰźv@Jxe(M/<3>SW5Ͳn(0ؓ+$ Y0YKg>B~1GIs[|*Ar/娐>̶P}^kq|3ǻdsJ^&Y rd}kZ1%.9K=sϛ|FzƺnҒAf뭴 [Υn } ]qSvQ[k 6Y;we ]'zT] $ܶy[ʾ(c{P?jhǪSKdG1G2z]XI-J^]/y UUtވH_/F7h-_@zί[>.>q /FyKq_OAyÍ~:hTs! .!/Ȣ B:|ې<佹롩(."xfv5F3,OCڐ l.KhnPcL]@{1~łruFvWjTq@6o3g|9b²`:\Y|7{c}9gBIT4r2.SKJe uAޖx$!2:<]O?3(TMB}B +w~l;y.wza=sbBaӋ\ufmihאoٿĭ0qhE2,^ gżVv;F ᰴIWc~GէDz"*VRT~'BC[1P.а66-b~)";ݹo=`[M2JㆿD+cQ4c}ϗb.G 8#h5ň=;,(_c#YO(jMLQ.1:aPxQhkgTba.^g.cq%vEm_1 [1FLxzvgn Sqc2adB%!=!<ȏY v2rV]W>njjx( E3lڿnƂqԫZᓴm/ KLmt,zAyrL|b,;e0FH@OI`sƇnY;E3S6oMA6E`m%hCQ/lRNW3g6jjxPeے1 u:@7L[աx.(Uxww^rRzvN_^5afHIƍQp1 hYngs1C.C 3\uRӏrVۏZ .0;1C 02?m.K YJgM6:iτY*ggIW)~AլC7K ).9y*b8Wq>]ˉb6%όs|Fp4UG ,΋U H9CEȓCㄡ՘@`v󥍕qـfn|L׶e ;_50ы08٫OO!4;ғ;(J7 qҘq `RWmD)(f0 ѨyIɥ1|OFɸNؤbVS1h BR{\bg|[O;fqbpapF_w7? TH{g;@.z-5M[HʙN .њw,40lFZhv|5 KyGA-ŶΩGU܋MTŬHp6|Β|4NpR!h,6G7BK{y{u٠I ǝ<w^9ýaie,"`'p>m8H;oWE`,j 3 aT%fo5#]]pt'' /p:y؂ȬCG>FUm r 6 !T =NBwJY#ADGgsjP)+cqǢ9:NM<{;e'+:h!8aMyufo,GKqN͇5?6d$ b/>ׅnqyy$o辮aLSy^[+G hLډTp$t:Fe7/fY <لOH^45c>;*e44?6|tۘ1 4Pd%Ѹ@aq Eٞ^`!&Pt W.u s>!X${?V[i6Ctu& dXJ IoC'^9W/Y*CA7!L8I_Gb'yLΚNw: 1U 54 MN3WG>sT31_p;{ lO 3O~=7jC˶ƦTOn t[|j6Tl"p^d%?*^ ?v۴n 5^Tḙ3Q{>Q׀DžZv|>_ǥLAqESX)cLLt,9L fg%a< E|1S&*[3]͞U_^rEe< ԕIWg!j1MZ@P=[Dx1lf D_p&?bK2Ff[/IlP*2q!%g%GC 4f4Ghԕ.gy;3%ԍw >1sj9M:#43r?O@N9IȌj?Ov2RUia,su6&!@>JP9Jȓc Jl/iD &t{[YBf8ۇեX2Jl) Jtȝx7pfCXHSw:)aC_YSR7{g.WOnWM G0xTeP!ߔt8QS/_uM$EfVLIP O}rWl\+:G1!f~x};>)(\F3Q"ԨP̹TFCcV\07Ŏ$ o]Ǯ_xsݒ!i]"+qvx Lqdj%4L\CwY5&Xi%ulSV,1##0fq:9dwO|P}+lrLm&%vv / 4&َ'{6wp9}(=]" $G5LncםWWS5:'wWA>*Zw^C`_J%L92?p`9ޅm!rIV$dǨF-GUyeP-%3{f4莅?y wh-\Ϥv!E_O 7Hu EܡiR`:& *,p X+_톅h;&R0Ev/S+EWd'^F3ETJs4jLQ< Kq7QafSWrir2FtmEXt O56 GAcd# X i="~ۀ[؄F-36ďՎz?+N\_Q 2җWv0TYg2~?5}c0SĶ;yXG4~"| $Ch9&a(*|Ca$T/ۭj T0[' uGNô|jaͿ^g>*ΘG9_1>^ oyqpbb3(_U{geQCU; Q&_ABCjȠPc"nȪ79e͝}_V DđSI<ᩚݑ"u+=wi̕vDKZ᭥v'V'hʼndb(Ak+uIU&աeH#V9 C]k 0Z =ݠ] C[{6Xm&kܽ<n !dzj9s#HƆL >*29̝(G$p%d )ALQZGTW8DŸ9i|quBnLlaJ=_r)PW0W@3)E7co.(Pڊ7^H:Ah`UD컂#NgV%5IQٿ1qd\Ȥ (@GӋ>!JڰP&V dW' JҠ/HKSNϿ 0N~x;㽱">NFvЄnfОe e[~B׌wi. _v|q*ӆi4 +GCq Mln(.B ' o "QۢncQ6{hG 2 v(ƅĠJgnax_F1%i>sؖMGi4*KIH>8ۑQ{U.b+. H:\NsO,%偫0k`i?x k)Rxk>7V"0('؃QI%G߉SʜZaJ W͸IT/_<6J{j^8zh*j=yZi8tx>ڟOj)Cl fX"Ko% !f-% {,OKpt'Ke~"Rx,Ϭ!hɤR##'ܮ43:8,8(b@5}b8?$jt4)Wp:Z AB}9eho>J W9Z;sM-3 ˪bӹ$C'-?h?+n׎WBm)B8?п_NM\v2~JQeCS'rMCti$1XRyZ,@2o 5db~1G@wN1aJ#SqNrXS rZ& $|;AKKΥ2ItPYEgX`p@-t&k"RO:c!/9K< [XXM06eD=vuE_+=ZSh8uV}bB(490d+RB#9aU͟r]lYQ./5{њ$XVpnN\o7G9BX%z\^Vx,U?0Oֹ\+3M!$F5lxjMz1))ŌT Df"I zHzK:Kٻ$YqSB')ϚkF 3dy5CyU'6'W`k4T@lb'ffl$'Od\I5q@ |,E!&t4)rokbn S߿z=Hiسvߔ={`.QP"d.GdDp8V! 7Si#bXYxwh` ͞: ,ߎ4 ̘mV?Z"SSC: 0[ulc}9Dk t7/:g&pZ5M'Za>\bծu#TTľQyd-5K'B3B|uz=^)=:NM_(g'z01 E%1ʉ l7$`;{'f \ArS'X8;ٲuzRD8< D`'eߞn Ǥ5y =Hv؟Absba:}PČBnMTT5ƈ|5"[H(/|dOӂ(v[eU*Q  X} ΥMSwZ򔟀ɓ`{sgwWP[{vi/Ȋش$Jʸ —cC*D16 W|E\[`鴹Y6]Rkj:[ILYBsFweC ꮤq."ot6̡5h@NlÉ=)3r&m0 W t-@[ Nї\Ѳ;oī詽.fсdk![AL<X| kL2 5V"R̆oxۊhYUN|֯Y|UFݠNoZ߻baTgYOD]K"S;9׸X]hMK !};ru[w2%.)|hT& (4 M#7pi⻷D!w{nt@ 9a({33Gm'}^is #V7!.bfMUskUIڣ4OB;*O4 xL|n$X:|4* GMc26X[$Ofή*ƥ;Ro\O/<^u傥/bTͬ lUTMťZ"Geb'Qa'Ivy#*'˸:L_ɼAHIBt17%9(ȧ'%Ewb4`Pc v2ϔ_%@sōǒ JuM0ˍ65/[9ф.z2;]ׅ«׸w.Cbo_)6'z]UŒ7Pu3iVMщ/<[)UOU cpawv5 a0kV2o?R:<4NAۮi jKUOz9xa<7Hٝ_!ikZI]>]q,iQ#vlޝ tOPoWC%}@5lUC~D1^| MxPăD+3奱 3!MQ?}W싄GV~yyoֺ_9`O@<'%N<.T/ApM4Y#"c4"_EAdg[EdM_1f AP1{=dә|8^;qE _y*c}0dYпNMa`a[˜0D$Tdx# ~Kf}CQƖQR{4.1 =a^4cA/VwŖKXjs1_X>@g# h'!v@gӋ<鞂lӓ;Of)dh<{*OJ&yOFԩp1K(=Ga>GT[HB$H<$c&sOCq:[n_}Lj^_s( ^^/jmb\ wVhnK<=aA\mqv+xL&xyD ۲%#Җ'KTP2@6ĐPu BثAy"b׋0I|9nٴkNzݟ4/5B REd:BhA73je/E+٬ 5]̦aV9}L>-ίpKw(.sKy٭m`ņrp$&6 mÉm4䲮"g)͒>!qW^tu, MdɛPR%KElL @fA\q]j230\˄AS=0]48YT'hU8mkDZ@(YJKZbY,x^4. d%"ab*Af>6 Ye;ǝBxSvjPĴp( O@ET vtKCq瘇l\,݌.%NIT}+0Y("RRC+35 ̔j+O/"Oؖ%Oj>+&Exxk:Nl n۝ޛoopmo۝;oޚ O?gfuN4+k%"Ӽǣ}xǏ+_|l~giOa: o8әE/k՘Hdtmvcnp&E!DjEO j٩|0>V>Zaα5r~k+sRP+L3?#d\%1K YޛVY+rȠ(* 5y"sXԌ,xQ&) "OεHNEoHߧٸ^E&KvP]-xzqjDH`m#<>T,5^E GVE3لyvnkqݡ$eE8^ũl!s}Ps'}TrnpH,1b~ r(?7qJ?_:B<++GgjΛ v9Zgαӧ {ͪ7"7:3۪muPeWǡ_%.7c{;A9 <)iWP]} Ys5_.LF$ QKYo'''q(Ek* 3M'>uٟԧˠ|{FDlժOo|aL@ipL%j2#C+xG>/յ_Jӕ!~sooi|i8p&D6ӆ.0Q$kNExEy|Q ;77dÓLZԫY1G6qMURoYO#rT;;JjlcˋF|[̂uqҍ"]wTh.d1KH >qeF>{)Aa=Ŕ=yrr}W~;2u,oVdKdI`o!vXM~M]QOُ>&.'(DB5f_ ~ p>f^n1=7RRj}27/KA> lpo;8<| O-. J=EY|¹Bk{:Nѹ#d2ƿzIz̔<If;mݺ^FqIԹHAWO:צ6SaR[#UErޟ˦~z^%ss%|(ިT鮜q<Ҍ>6 ֩[̇ejCC,bU3#V|6r93ōZ/vٿyCMu{@*oޢgiJyW'9<+$ZS{|f51e-?}!G5 gѵLtU{l-\΁t(y;y[*5$s8t?c+_\iWd{ Na$~1*pJ`ʙ+~w!8t:;k<ÈZ׋MzZp6'IBQ6*LT~'RwO;yѥ ?8KGOx 6p)BrUdb_Za#>bR2Tn89wn<$V3;At%DEB~xvJ67]U=~],*}<,VK"/AKTP;Rjodgb6]I0֢)6Q7 d.;U3V:2֮mu^bx6op7Eހ5Iwj,鬇ҙ͡1c)tfZQJVvbUbVxe<̻GPz(gK'c@e:8[{g8u]u/`26hNٌq,EF~g^o(9ܪA)޲>Z`JH3Bnd3@e"E=pB#/98nݕtaw93CޔfUN|4f*wf:kz 1{m_؁g6KmIItiYDäN&O56եkk(Cv1鏻mr1F&]aUs~VωIF7׺+ ZjxlndX) $D{W1ԐָJnN>g[Z.k?9 ǎs\,E~:z}$e$f &/ 9btD^\{s,|O>ͱj{'<[-&Q0%\Jƶռǃu&S.}ӧp{Ǵv8[}z~/GVe|49x_iޞLUJ ٘<#// 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. // This features file defines extension APIs implemented under src/extensions. // See chrome/common/extensions/api/_features.md to understand this file, as // well as feature.h, simple_feature.h, and feature_provider.h. // // Note that specifying "web_page", "blessed_web_page", or "all" as a context // type will require manually updating chrome/renderer/resources/dispatcher.cc. { "alarms": { "dependencies": ["permission:alarms"], "contexts": ["blessed_extension"] }, "app.runtime": [{ "channel": "stable", "contexts": ["blessed_extension", "lock_screen_extension"], "extension_types": ["platform_app"], "noparent": true }, { "channel": "stable", "component_extensions_auto_granted": false, "contexts": ["blessed_extension"], "extension_types": ["extension"], "noparent": true, "whitelist": [ "2FC374607C2DF285634B67C64A2E356C607091C3", // Quickoffice "3727DD3E564B6055387425027AD74C58784ACC15", // Quickoffice internal "12E618C3C6E97495AAECF2AC12DEB082353241C6" // QO component extension ] }], "app.window": [{ "channel": "stable", "contexts": ["blessed_extension", "lock_screen_extension"], "extension_types": ["platform_app"], "noparent": true }, { "channel": "stable", "contexts": ["blessed_extension"], "extension_types": ["extension"], "noparent": true, "component_extensions_auto_granted": false, "whitelist": [ "B9EF10DDFEA11EF77873CC5009809E5037FC4C7A", // Google input tools "06BE211D5F014BAB34BC22D9DDA09C63A81D828E", // Official xkb extension "F94EE6AB36D6C6588670B2B01EB65212D9C64E33" // Open source xkb extension ] }], "app.currentWindowInternal": { "noparent": true, "internal": true, "channel": "stable", "contexts": ["blessed_extension", "lock_screen_extension"] }, "app.currentWindowInternal.setShape": { "dependencies": ["permission:app.window.shape"], "contexts": ["blessed_extension"] }, // The API for the *embedder* of appview. Appview has both an embedder and // guest API, which are different. "appViewEmbedderInternal": { "internal": true, "contexts": ["blessed_extension"], "dependencies": ["permission:appview"] }, // Note that exposing this doesn't necessarily expose AppView, // appViewEmbedderInternal is required for that. // See http://crbug.com/437891. "appViewGuestInternal": { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, "audio": { "dependencies": ["permission:audio"], "contexts": ["blessed_extension"] }, "bluetooth": [{ "dependencies": ["manifest:bluetooth"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bluetooth-pairing/*", "chrome://settings/*" ] }], "bluetoothLowEnergy": { "dependencies": ["manifest:bluetooth"], "contexts": ["blessed_extension"], "platforms": ["chromeos", "linux"] }, "bluetoothPrivate": [{ "dependencies": ["permission:bluetoothPrivate"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bluetooth-pairing/*", "chrome://settings/*" ] }], "bluetoothSocket": { "dependencies": ["manifest:bluetooth"], "contexts": ["blessed_extension"] }, "clipboard": { "dependencies": ["permission:clipboard"], "contexts": ["blessed_extension"] }, "clipboard.onClipboardDataChanged": { "dependencies": ["permission:clipboardRead"] }, "clipboard.setImageData": { "dependencies": ["permission:clipboardWrite"] }, "declarativeNetRequest": { "dependencies": ["permission:declarativeNetRequest"], "contexts": ["blessed_extension"] }, "declarativeWebRequest": { "dependencies": ["permission:declarativeWebRequest"], "contexts": ["blessed_extension"] }, "diagnostics": { "dependencies": ["permission:diagnostics"], "extension_types": ["platform_app"], "contexts": ["blessed_extension"] }, "displaySource": { "dependencies": ["permission:displaySource"], "contexts": ["blessed_extension"] }, "dns": { "dependencies": ["permission:dns"], "contexts": ["blessed_extension"] }, "documentScan": { "dependencies": ["permission:documentScan"], "contexts": ["blessed_extension"] }, // This is not a real API, only here for documentation purposes. // See http://crbug.com/275944 for background. "extensionTypes": { "internal": true, "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"], "contexts": ["blessed_extension"] }, "extensionViewInternal": [ { "internal": true, "contexts": ["blessed_extension"], "dependencies": ["permission:extensionview"] }, { "internal": true, "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://cast/*", "chrome://media-router/*" ] } ], "events": { "internal": true, "channel": "stable", "extension_types": ["platform_app", "extension"], "contexts": "all", "matches": [""] }, "feedbackPrivate": { "dependencies": ["permission:feedbackPrivate"], "contexts": ["blessed_extension"] }, "feedbackPrivate.readLogSource": { "platforms": ["chromeos"], "session_types": ["kiosk"] }, "fileSystem": { "dependencies": ["permission:fileSystem"], "contexts": ["blessed_extension"] }, "guestViewInternal": [ { "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, { "internal": true, "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://cast/*", "chrome://extensions-frame/*", "chrome://extensions/*", "chrome://chrome-signin/*", "chrome://media-router/*", "chrome://mobilesetup/*", "chrome://oobe/*" ] } ], "hid": { "dependencies": ["permission:hid"], "contexts": ["blessed_extension"] }, "hid.getUserSelectedDevices": { "contexts": ["blessed_extension"], "channel": "dev", "dependencies": ["permission:hid"] }, "idle": { "dependencies": ["permission:idle"], "contexts": ["blessed_extension"] }, "lockScreen.data": { "dependencies": ["permission:lockScreen"], "contexts": ["blessed_extension", "lock_screen_extension"] }, "lockScreen.data.create": { "contexts": ["lock_screen_extension"] }, "management": [{ "dependencies": ["permission:management"], "contexts": ["blessed_extension"], "default_parent": true }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://extensions/*", "chrome://extensions-frame/*", "chrome://chrome/extensions/*", "chrome://settings/*" ] }], "management.getPermissionWarningsByManifest": { "dependencies": [], "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"] }, "management.getSelf": { "dependencies": [], "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"] }, "management.uninstallSelf": { "dependencies": [], "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"] }, "mediaPerceptionPrivate": { "dependencies": ["permission:mediaPerceptionPrivate"], "contexts": ["blessed_extension"] }, "metricsPrivate": [{ "dependencies": ["permission:metricsPrivate"], "contexts": ["blessed_extension"], "default_parent": true }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://bookmarks/*", "chrome://extensions/*", "chrome://settings/*" ] }], "metricsPrivate.getIsCrashReportingEnabled": { "whitelist": [ // This function inherits the extension restrictions of metricsPrivate, // but also requires whitelisting. New uses of this function should get // /tools/metrics/OWNERS approval of the usage before adding entries // below. See crbug.com/374199. "2FC374607C2DF285634B67C64A2E356C607091C3", // Quickoffice "3727DD3E564B6055387425027AD74C58784ACC15", // Quickoffice internal "12E618C3C6E97495AAECF2AC12DEB082353241C6", // QO component extension "3727DD3E564B6055387425027AD74C58784ACC15", // Editor "C41AD9DCD670210295614257EF8C9945AD68D86E", // Google Now // TODO(michaelpg): Determine whether these three extensions (D5736E4, // D57DE39, 3F65507) require this feature: crbug.com/652433. "D5736E4B5CF695CB93A2FB57E4FDC6E5AFAB6FE2", // http://crbug.com/312900. "D57DE394F36DC1C3220E7604C575D29C51A6C495", // http://crbug.com/319444. "3F65507A3B39259B38C8173C6FFA3D12DF64CCE9", // http://crbug.com/371562. "D7986543275120831B39EF28D1327552FC343960", // http://crbug.com/378067 "A291B26E088FA6BA53FFD72F0916F06EBA7C585A", // http://crbug.com/378067 "07BD6A765FFC289FF755D7CAB2893A40EC337FEC", // http://crbug.com/456214 "896B85CC7E913E11C34892C1425A093C0701D386", // http://crbug.com/456214 "11A01C82EF355E674E4F9728A801F5C3CB40D83F", // http://crbug.com/456214 "F410C88469990EE7947450311D24B8AF2ADB2595", // http://crbug.com/456214 "63ED55E43214C211F82122ED56407FF1A807F2A3", // Media Router Dev "226CF815E39A363090A1E547D53063472B8279FA", // Media Router Stable // TODO (ntang) Remove the following 2 hashes by 12/31/2017. "B620CF4203315F9F2E046EDED22C7571A935958D", // http://crbug.com/510270 "B206D8716769728278D2D300349C6CB7D7DE2EF9", // http://crbug.com/510270 "2B6C6A4A5940017146F3E58B7F90116206E84685", // http://crbug.com/642141 "B6C2EFAB3EC3BF6EF03701408B6B09A67B2D0069", // http://crbug.com/642141 "96FF2FFA5C9173C76D47184B3E86D267B37781DE", // http://crbug.com/642141 "0136FCB13DB29FD5CD442F56E59E53B61F1DF96F" // http://crbug.com/642141 ] }, "mimeHandlerPrivate": { "dependencies": ["manifest:mime_types_handler"], "contexts": ["blessed_extension"] }, "mojoPrivate": { "contexts": ["blessed_extension"], "channel": "stable", "extension_types": ["platform_app", "extension"], "whitelist": [ "63ED55E43214C211F82122ED56407FF1A807F2A3", // Media Router Dev "226CF815E39A363090A1E547D53063472B8279FA" // Media Router Stable ] }, "networking.config": { "dependencies": ["permission:networking.config"], "contexts": ["blessed_extension"] }, "networking.onc": { "dependencies": ["permission:networking.onc"], "contexts": ["blessed_extension"], "source": "networkingPrivate" }, "networkingPrivate": [{ "dependencies": ["permission:networkingPrivate"], "contexts": ["blessed_extension"], // TODO(tbarzic): networkingPrivate is being renamed to networking.onc. // The goal is to eventually remove networkingPrivate API in favour of // networking.onc, but until current usages are migrated to the new // name, use API aliasing to expose the API under both names. // (http://crbug.com/672186). "alias": "networking.onc" }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://network/*", "chrome://oobe/*", "chrome://internet-config-dialog/*", "chrome://internet-detail-dialog/*", "chrome://settings/*" ] }], "power": { "dependencies": ["permission:power"], "contexts": ["blessed_extension"] }, "printerProvider": { "dependencies": ["permission:printerProvider"], "contexts": ["blessed_extension"] }, "printerProviderInternal": { "internal": true, "dependencies": ["permission:printerProvider"], "contexts": ["blessed_extension"] }, "runtime": { "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"], "contexts": ["blessed_extension", "lock_screen_extension"] }, "runtime.getManifest": { "contexts": [ "blessed_extension", "lock_screen_extension", "unblessed_extension", "content_script" ] }, "runtime.connect": { // Everything except WebUI. "contexts": [ "blessed_web_page", "content_script", "blessed_extension", "lock_screen_extension", "unblessed_extension", "web_page" ], "matches": [""] }, "runtime.connectNative": { "dependencies": ["permission:nativeMessaging"], "contexts": ["blessed_extension"] }, "runtime.getURL": { "contexts": [ "blessed_extension", "lock_screen_extension", "unblessed_extension", "content_script" ] }, "runtime.id": { "contexts": [ "blessed_extension", "lock_screen_extension", "unblessed_extension", "content_script" ] }, "runtime.lastError": { "contexts": "all", "extension_types": "all", "matches": [""] }, "runtime.onConnect": { "contexts": [ "blessed_extension", "lock_screen_extension", "unblessed_extension", "content_script" ] }, "runtime.onMessage": { "contexts": [ "blessed_extension", "lock_screen_extension", "unblessed_extension", "content_script" ] }, "runtime.sendMessage": { // Everything except WebUI. "contexts": [ "blessed_web_page", "content_script", "blessed_extension", "lock_screen_extension", "unblessed_extension", "web_page" ], "matches": [""] }, "runtime.sendNativeMessage": { "dependencies": ["permission:nativeMessaging"], "contexts": ["blessed_extension"] }, "serial": { "dependencies": ["permission:serial"], "contexts": ["blessed_extension"] }, "socket": { "dependencies": ["permission:socket"], "contexts": ["blessed_extension"] }, "sockets.tcp": { "dependencies": ["manifest:sockets"], "contexts": ["blessed_extension"] }, "sockets.tcpServer": { "dependencies": ["manifest:sockets"], "contexts": ["blessed_extension"] }, "sockets.udp": { "dependencies": ["manifest:sockets"], "contexts": ["blessed_extension"] }, "storage": { "dependencies": ["permission:storage"], "contexts": ["blessed_extension", "unblessed_extension", "content_script"] }, "system.cpu": { "dependencies": ["permission:system.cpu"], "contexts": ["blessed_extension"] }, "system.display": [{ "dependencies": ["permission:system.display"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://settings/*" ] }], "system.memory": { "dependencies": ["permission:system.memory"], "contexts": ["blessed_extension"] }, "system.network": { "dependencies": ["permission:system.network"], "contexts": ["blessed_extension"] }, "system.storage": { "dependencies": ["permission:system.storage"], "contexts": ["blessed_extension"] }, "system.storage.getAvailableCapacity": { "channel": "dev" }, "test": [{ "channel": "stable", "extension_types": "all", // Everything except web pages and WebUI. WebUI is declared in a separate // rule to keep the "matches" property isolated. "contexts": [ "blessed_extension", "blessed_web_page", "content_script", "extension_service_worker", "lock_screen_extension", "unblessed_extension" ] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://extensions/*", "chrome://extensions-frame/*", "chrome://chrome/extensions/*" ] }], "types": { "internal": true, "channel": "stable", "extension_types": ["extension", "legacy_packaged_app", "platform_app"], "contexts": ["blessed_extension"] }, "types.private": { // preferencesPrivate is the only API that uses types.private. // If any other APIs need it then they'll need to be added in // separate rules. "dependencies": ["permission:preferencesPrivate"], "contexts": ["blessed_extension"] }, "usb": { "dependencies": ["permission:usb"], "contexts": ["blessed_extension"] }, "virtualKeyboard": { "dependencies": ["permission:virtualKeyboard"], "contexts": ["blessed_extension"] }, "vpnProvider": { "dependencies": ["permission:vpnProvider"], "contexts": ["blessed_extension"] }, "webRequest": { "dependencies": ["permission:webRequest"], "contexts": ["blessed_extension"] }, "webRequestInternal": [{ "internal": true, "channel": "stable", "contexts": ["blessed_extension"] }, { // webview uses webRequestInternal API. "channel": "stable", "internal": true, "contexts": ["webui"], "matches": [ "chrome://chrome-signin/*", "chrome://media-router/*", "chrome://mobilesetup/*", "chrome://oobe/*" ] }], "webViewInternal": [{ "internal": true, "dependencies": ["permission:webview"], "contexts": ["blessed_extension"] }, { "internal": true, "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://chrome-signin/*", "chrome://media-router/*", "chrome://mobilesetup/*", "chrome://oobe/*" ] }], "webViewRequest": [{ "dependencies": ["permission:webview"], "contexts": ["blessed_extension"] }, { "channel": "stable", "contexts": ["webui"], "matches": [ "chrome://chrome-signin/*", "chrome://media-router/*", "chrome://mobilesetup/*", "chrome://oobe/*" ] }] } // 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 DocumentNatives = requireNative('document_natives'); var GuestViewContainer = require('guestViewContainer').GuestViewContainer; var IdGenerator = requireNative('id_generator'); function AppViewImpl(appviewElement) { GuestViewContainer.call(this, appviewElement, 'appview'); this.app = ''; this.data = ''; } AppViewImpl.prototype.__proto__ = GuestViewContainer.prototype; AppViewImpl.VIEW_TYPE = 'AppView'; // Add extra functionality to |this.element|. AppViewImpl.setupElement = function(proto) { var apiMethods = [ 'connect' ]; // Forward proto.foo* method calls to AppViewImpl.foo*. GuestViewContainer.forwardApiMethods(proto, apiMethods); } AppViewImpl.prototype.getErrorNode = function() { if (!this.errorNode) { this.errorNode = document.createElement('div'); this.errorNode.innerText = 'Unable to connect to app.'; this.errorNode.style.position = 'absolute'; this.errorNode.style.left = '0px'; this.errorNode.style.top = '0px'; this.errorNode.style.width = '100%'; this.errorNode.style.height = '100%'; this.element.shadowRoot.appendChild(this.errorNode); } return this.errorNode; }; AppViewImpl.prototype.buildContainerParams = function() { return { 'appId': this.app, 'data': this.data || {} }; }; AppViewImpl.prototype.connect = function(app, data, callback) { if (!this.elementAttached) { if (callback) { callback(false); } return; } this.app = app; this.data = data; this.guest.destroy(); this.guest.create(this.buildParams(), $Function.bind(function() { if (!this.guest.getId()) { var errorMsg = 'Unable to connect to app "' + app + '".'; window.console.warn(errorMsg); this.getErrorNode().innerText = errorMsg; if (callback) { callback(false); } return; } this.attachWindow$(); if (callback) { callback(true); } }, this)); }; GuestViewContainer.registerElement(AppViewImpl); // 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 registerHooks(api) { } function testDone(runNextTest) { // Use setTimeout here to allow previous test contexts to be // eligible for garbage collection. setTimeout(runNextTest, 0); } exports.$set('registerHooks', registerHooks); exports.$set('testDone', testDone); // 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 fileSystemNatives = requireNative('file_system_natives'); var nameToIds = {}; var idsToEntries = {}; function computeName(entry) { return entry.filesystem.name + ':' + entry.fullPath; } function computeId(entry) { var fileSystemId = fileSystemNatives.CrackIsolatedFileSystemName( entry.filesystem.name); if (!fileSystemId) return null; // Strip the leading '/' from the path. return fileSystemId + ':' + $String.slice(entry.fullPath, 1); } function registerEntry(id, entry) { var name = computeName(entry); nameToIds[name] = id; idsToEntries[id] = entry; } function getEntryId(entry) { var name = null; try { name = computeName(entry); } catch(e) { return null; } var id = nameToIds[name]; if (id != null) return id; // If an entry has not been registered, compute its id and register it. id = computeId(entry); registerEntry(id, entry); return id; } function getEntryById(id) { return idsToEntries[id]; } exports.$set('registerEntry', registerEntry); exports.$set('getEntryId', getEntryId); exports.$set('getEntryById', getEntryById); // 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(robwu): Fix indentation. var exceptionHandler = require('uncaught_exception_handler'); var eventNatives = requireNative('event_natives'); var logging = requireNative('logging'); var schemaRegistry = requireNative('schema_registry'); var sendRequest = require('sendRequest').sendRequest; var utils = require('utils'); var validate = require('schemaUtils').validate; // Schemas for the rule-style functions on the events API that // only need to be generated occasionally, so populate them lazily. var ruleFunctionSchemas = { __proto__: null, // These values are set lazily: // addRules: {}, // getRules: {}, // removeRules: {} }; // This function ensures that |ruleFunctionSchemas| is populated. function ensureRuleSchemasLoaded() { if (ruleFunctionSchemas.addRules) return; var eventsSchema = schemaRegistry.GetSchema("events"); var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event'); ruleFunctionSchemas.addRules = utils.lookup(eventType.functions, 'name', 'addRules'); ruleFunctionSchemas.getRules = utils.lookup(eventType.functions, 'name', 'getRules'); ruleFunctionSchemas.removeRules = utils.lookup(eventType.functions, 'name', 'removeRules'); } // A map of event names to the event object that is registered to that name. var attachedNamedEvents = {__proto__: null}; // A map of functions that massage event arguments before they are dispatched. // Key is event name, value is function. var eventArgumentMassagers = {__proto__: null}; // An attachment strategy for events that aren't attached to the browser. // This applies to events with the "unmanaged" option and events without // names. function NullAttachmentStrategy(event) { this.event_ = event; } $Object.setPrototypeOf(NullAttachmentStrategy.prototype, null); NullAttachmentStrategy.prototype.onAddedListener = function(listener) { // For named events, we still inform the messaging bindings when a listener // is registered to allow for native checking if a listener is registered. if (this.event_.eventName && this.event_.listeners.length == 0) { eventNatives.AttachUnmanagedEvent(this.event_.eventName); } }; NullAttachmentStrategy.prototype.onRemovedListener = function(listener) { if (this.event_.eventName && this.event_.listeners.length == 0) { this.detach(true); } }; NullAttachmentStrategy.prototype.detach = function(manual) { if (this.event_.eventName) eventNatives.DetachUnmanagedEvent(this.event_.eventName); }; NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) { // |ids| is for filtered events only. return this.event_.listeners; }; // Handles adding/removing/dispatching listeners for unfiltered events. function UnfilteredAttachmentStrategy(event) { this.event_ = event; } $Object.setPrototypeOf(UnfilteredAttachmentStrategy.prototype, null); UnfilteredAttachmentStrategy.prototype.onAddedListener = function(listener) { // Only attach / detach on the first / last listener removed. if (this.event_.listeners.length == 0) eventNatives.AttachEvent(this.event_.eventName, this.event_.eventOptions.supportsLazyListeners); }; UnfilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) { if (this.event_.listeners.length == 0) this.detach(true); }; UnfilteredAttachmentStrategy.prototype.detach = function(manual) { eventNatives.DetachEvent(this.event_.eventName, manual, this.event_.eventOptions.supportsLazyListeners); }; UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) { // |ids| is for filtered events only. return this.event_.listeners; }; function FilteredAttachmentStrategy(event) { this.event_ = event; this.listenerMap_ = {__proto__: null}; } $Object.setPrototypeOf(FilteredAttachmentStrategy.prototype, null); utils.defineProperty(FilteredAttachmentStrategy, 'idToEventMap', {__proto__: null}); FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) { var id = eventNatives.AttachFilteredEvent( this.event_.eventName, listener.filters || {}, this.event_.eventOptions.supportsLazyListeners); if (id == -1) throw new Error("Can't add listener"); listener.id = id; this.listenerMap_[id] = listener; FilteredAttachmentStrategy.idToEventMap[id] = this.event_; }; FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) { this.detachListener(listener, true); }; FilteredAttachmentStrategy.prototype.detachListener = function(listener, manual) { if (listener.id == undefined) throw new Error("listener.id undefined - '" + listener + "'"); var id = listener.id; delete this.listenerMap_[id]; delete FilteredAttachmentStrategy.idToEventMap[id]; eventNatives.DetachFilteredEvent( id, manual, this.event_.eventOptions.supportsLazyListeners); }; FilteredAttachmentStrategy.prototype.detach = function(manual) { for (var i in this.listenerMap_) this.detachListener(this.listenerMap_[i], manual); }; FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) { var result = []; for (var i = 0; i < ids.length; i++) $Array.push(result, this.listenerMap_[ids[i]]); return result; }; function parseEventOptions(opt_eventOptions) { return $Object.assign({ __proto__: null, }, { // Event supports adding listeners with filters ("filtered events"), for // example as used in the webNavigation API. // // event.addListener(listener, [filter1, filter2]); supportsFilters: false, // Events supports vanilla events. Most APIs use these. // // event.addListener(listener); supportsListeners: true, // Event supports lazy listeners, where an extension can register a // listener to be used to "wake up" a lazy context. supportsLazyListeners: true, // Event supports adding rules ("declarative events") rather than // listeners, for example as used in the declarativeWebRequest API. // // event.addRules([rule1, rule2]); supportsRules: false, // Event is unmanaged in that the browser has no knowledge of its // existence; it's never invoked, doesn't keep the renderer alive, and // the bindings system has no knowledge of it. // // Both events created by user code (new chrome.Event()) and messaging // events are unmanaged, though in the latter case the browser *does* // interact indirectly with them via IPCs written by hand. unmanaged: false, }, opt_eventOptions); } // Event object. If opt_eventName is provided, this object represents // the unique instance of that named event, and dispatching an event // with that name will route through this object's listeners. Note that // opt_eventName is required for events that support rules. // // Example: // var Event = require('event_bindings').Event; // chrome.tabs.onChanged = new Event("tab-changed"); // chrome.tabs.onChanged.addListener(function(data) { alert(data); }); // Event.dispatch("tab-changed", "hi"); // will result in an alert dialog that says 'hi'. // // If opt_eventOptions exists, it is a dictionary that contains the boolean // entries "supportsListeners" and "supportsRules". // If opt_webViewInstanceId exists, it is an integer uniquely identifying a // tag within the embedder. If it does not exist, then this is an // extension event rather than a event. function EventImpl(opt_eventName, opt_argSchemas, opt_eventOptions, opt_webViewInstanceId) { this.eventName = opt_eventName; this.argSchemas = opt_argSchemas; this.listeners = []; this.eventOptions = parseEventOptions(opt_eventOptions); this.webViewInstanceId = opt_webViewInstanceId || 0; if (!this.eventName) { if (this.eventOptions.supportsRules) throw new Error("Events that support rules require an event name."); // Events without names cannot be managed by the browser by definition // (the browser has no way of identifying them). this.eventOptions.unmanaged = true; } // Track whether the event has been destroyed as a sanity check. this.destroyed = false; if (this.eventOptions.unmanaged) this.attachmentStrategy = new NullAttachmentStrategy(this); else if (this.eventOptions.supportsFilters) this.attachmentStrategy = new FilteredAttachmentStrategy(this); else this.attachmentStrategy = new UnfilteredAttachmentStrategy(this); } $Object.setPrototypeOf(EventImpl.prototype, null); // callback is a function(args, dispatch). args are the args we receive from // dispatchEvent(), and dispatch is a function(args) that dispatches args to // its listeners. function registerArgumentMassager(name, callback) { if (eventArgumentMassagers[name]) throw new Error("Massager already registered for event: " + name); eventArgumentMassagers[name] = callback; } // Dispatches a named event with the given argument array. The args array is // the list of arguments that will be sent to the event callback. // |listenerIds| contains the ids of matching listeners, or is an empty array // for all listeners. function dispatchEvent(name, args, listenerIds) { var event = attachedNamedEvents[name]; if (!event) return; var dispatchArgs = function(args) { var result = event.dispatch_(args, listenerIds); if (result) logging.DCHECK(!result.validationErrors, result.validationErrors); return result; }; if (eventArgumentMassagers[name]) eventArgumentMassagers[name](args, dispatchArgs); else dispatchArgs(args); } // Registers a callback to be called when this event is dispatched. EventImpl.prototype.addListener = function(cb, filters) { if (!this.eventOptions.supportsListeners) throw new Error("This event does not support listeners."); if (this.eventOptions.maxListeners && this.getListenerCount_() >= this.eventOptions.maxListeners) { throw new Error("Too many listeners for " + this.eventName); } if (filters) { if (!this.eventOptions.supportsFilters) throw new Error("This event does not support filters."); if (filters.url && !(filters.url instanceof Array)) throw new Error("filters.url should be an array."); if (filters.serviceType && !(typeof filters.serviceType === 'string')) { throw new Error("filters.serviceType should be a string.") } } var listener = {callback: cb, filters: filters}; this.attach_(listener); $Array.push(this.listeners, listener); }; EventImpl.prototype.attach_ = function(listener) { this.attachmentStrategy.onAddedListener(listener); if (this.listeners.length == 0) { if (this.eventName) { if (attachedNamedEvents[this.eventName]) { throw new Error("Event '" + this.eventName + "' is already attached."); } attachedNamedEvents[this.eventName] = this; } } }; // Unregisters a callback. EventImpl.prototype.removeListener = function(cb) { if (!this.eventOptions.supportsListeners) throw new Error("This event does not support listeners."); var idx = this.findListener_(cb); if (idx == -1) return; var removedListener = $Array.splice(this.listeners, idx, 1)[0]; this.attachmentStrategy.onRemovedListener(removedListener); if (this.listeners.length == 0) { if (this.eventName) { if (!attachedNamedEvents[this.eventName]) { throw new Error( "Event '" + this.eventName + "' is not attached."); } delete attachedNamedEvents[this.eventName]; } } }; // Test if the given callback is registered for this event. EventImpl.prototype.hasListener = function(cb) { if (!this.eventOptions.supportsListeners) throw new Error("This event does not support listeners."); return this.findListener_(cb) > -1; }; // Test if any callbacks are registered for this event. EventImpl.prototype.hasListeners = function() { return this.getListenerCount_() > 0; }; // Returns the number of listeners on this event. EventImpl.prototype.getListenerCount_ = function() { if (!this.eventOptions.supportsListeners) throw new Error("This event does not support listeners."); return this.listeners.length; }; // Returns the index of the given callback if registered, or -1 if not // found. EventImpl.prototype.findListener_ = function(cb) { for (var i = 0; i < this.listeners.length; i++) { if (this.listeners[i].callback == cb) { return i; } } return -1; }; EventImpl.prototype.dispatch_ = function(args, listenerIDs) { if (this.destroyed) { throw new Error(this.eventName + ' was already destroyed'); } if (!this.eventOptions.supportsListeners) throw new Error("This event does not support listeners."); if (this.argSchemas && logging.DCHECK_IS_ON()) { try { validate(args, this.argSchemas); } catch (e) { e.message += ' in ' + this.eventName; throw e; } } // Make a copy of the listeners in case the listener list is modified // while dispatching the event. var listeners = $Array.slice( this.attachmentStrategy.getListenersByIDs(listenerIDs)); var results = []; for (var i = 0; i < listeners.length; i++) { try { var result = this.wrapper.dispatchToListener(listeners[i].callback, args); if (result !== undefined) $Array.push(results, result); } catch (e) { exceptionHandler.handle('Error in event handler for ' + (this.eventName ? this.eventName : '(unknown)'), e); } } if (results.length) return {results: results}; } // Can be overridden to support custom dispatching. EventImpl.prototype.dispatchToListener = function(callback, args) { return $Function.apply(callback, null, args); } // Dispatches this event object to all listeners, passing all supplied // arguments to this function each listener. EventImpl.prototype.dispatch = function(varargs) { return this.dispatch_($Array.slice(arguments), undefined); }; // Detaches this event object from its name. EventImpl.prototype.detach_ = function() { this.attachmentStrategy.detach(false); }; EventImpl.prototype.destroy_ = function() { this.listeners.length = 0; this.detach_(); this.destroyed = true; }; EventImpl.prototype.addRules = function(rules, opt_cb) { if (!this.eventOptions.supportsRules) throw new Error("This event does not support rules."); // Takes a list of JSON datatype identifiers and returns a schema fragment // that verifies that a JSON object corresponds to an array of only these // data types. function buildArrayOfChoicesSchema(typesList) { return { __proto__: null, 'type': 'array', 'items': { __proto__: null, 'choices': $Array.map(typesList, function(el) { return { __proto__: null, '$ref': el, }; }), } }; } // Validate conditions and actions against specific schemas of this // event object type. // |rules| is an array of JSON objects that follow the Rule type of the // declarative extension APIs. |conditions| is an array of JSON type // identifiers that are allowed to occur in the conditions attribute of each // rule. Likewise, |actions| is an array of JSON type identifiers that are // allowed to occur in the actions attribute of each rule. function validateRules(rules, conditions, actions) { var conditionsSchema = buildArrayOfChoicesSchema(conditions); var actionsSchema = buildArrayOfChoicesSchema(actions); $Array.forEach(rules, function(rule) { validate([rule.conditions], [conditionsSchema]); validate([rule.actions], [actionsSchema]); }); }; if (!this.eventOptions.conditions || !this.eventOptions.actions) { throw new Error('Event ' + this.eventName + ' misses ' + 'conditions or actions in the API specification.'); } validateRules(rules, this.eventOptions.conditions, this.eventOptions.actions); ensureRuleSchemasLoaded(); // We remove the first parameter from the validation to give the user more // meaningful error messages. validate([this.webViewInstanceId, rules, opt_cb], $Array.slice(ruleFunctionSchemas.addRules.parameters, 1)); sendRequest( "events.addRules", [this.eventName, this.webViewInstanceId, rules, opt_cb], ruleFunctionSchemas.addRules.parameters); } EventImpl.prototype.removeRules = function(ruleIdentifiers, opt_cb) { if (!this.eventOptions.supportsRules) throw new Error("This event does not support rules."); ensureRuleSchemasLoaded(); // We remove the first parameter from the validation to give the user more // meaningful error messages. validate([this.webViewInstanceId, ruleIdentifiers, opt_cb], $Array.slice(ruleFunctionSchemas.removeRules.parameters, 1)); sendRequest("events.removeRules", [this.eventName, this.webViewInstanceId, ruleIdentifiers, opt_cb], ruleFunctionSchemas.removeRules.parameters); } EventImpl.prototype.getRules = function(ruleIdentifiers, cb) { if (!this.eventOptions.supportsRules) throw new Error("This event does not support rules."); ensureRuleSchemasLoaded(); // We remove the first parameter from the validation to give the user more // meaningful error messages. validate([this.webViewInstanceId, ruleIdentifiers, cb], $Array.slice(ruleFunctionSchemas.getRules.parameters, 1)); sendRequest( "events.getRules", [this.eventName, this.webViewInstanceId, ruleIdentifiers, cb], ruleFunctionSchemas.getRules.parameters); } function Event() { privates(Event).constructPrivate(this, arguments); } utils.expose(Event, EventImpl, { functions: [ 'addListener', 'removeListener', 'hasListener', 'hasListeners', 'dispatchToListener', 'dispatch', 'addRules', 'removeRules', 'getRules', ], }); // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc. exports.$set('Event', Event); exports.$set('dispatchEvent', dispatchEvent); exports.$set('parseEventOptions', parseEventOptions); exports.$set('registerArgumentMassager', registerArgumentMassager); // 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 ExtensionOptionsConstants = require('extensionOptionsConstants').ExtensionOptionsConstants; var ExtensionOptionsEvents = require('extensionOptionsEvents').ExtensionOptionsEvents; var GuestViewContainer = require('guestViewContainer').GuestViewContainer; function ExtensionOptionsImpl(extensionoptionsElement) { GuestViewContainer.call(this, extensionoptionsElement, 'extensionoptions'); new ExtensionOptionsEvents(this); }; ExtensionOptionsImpl.prototype.__proto__ = GuestViewContainer.prototype; ExtensionOptionsImpl.VIEW_TYPE = 'ExtensionOptions'; ExtensionOptionsImpl.prototype.onElementAttached = function() { this.createGuest(); } ExtensionOptionsImpl.prototype.buildContainerParams = function() { var params = {}; for (var i in this.attributes) { params[i] = this.attributes[i].getValue(); } return params; }; ExtensionOptionsImpl.prototype.createGuest = function() { // Destroy the old guest if one exists. this.guest.destroy(); this.guest.create(this.buildParams(), $Function.bind(function() { if (!this.guest.getId()) { // Fire a createfailed event here rather than in ExtensionOptionsGuest // because the guest will not be created, and cannot fire an event. var createFailedEvent = new Event('createfailed', { bubbles: true }); this.dispatchEvent(createFailedEvent); } else { this.attachWindow$(); } }, this)); }; GuestViewContainer.registerElement(ExtensionOptionsImpl); // Exports. exports.$set('ExtensionOptionsImpl', ExtensionOptionsImpl); // 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 module implements the attributes of the tag. var GuestViewAttributes = require('guestViewAttributes').GuestViewAttributes; var ExtensionOptionsConstants = require('extensionOptionsConstants').ExtensionOptionsConstants; var ExtensionOptionsImpl = require('extensionOptions').ExtensionOptionsImpl; // ----------------------------------------------------------------------------- // ExtensionAttribute object. // Attribute that handles extension binded to the extensionoptions. function ExtensionAttribute(view) { GuestViewAttributes.Attribute.call( this, ExtensionOptionsConstants.ATTRIBUTE_EXTENSION, view); } ExtensionAttribute.prototype.__proto__ = GuestViewAttributes.Attribute.prototype; ExtensionAttribute.prototype.handleMutation = function(oldValue, newValue) { // Once this attribute has been set, it cannot be unset. if (!newValue && oldValue) { this.setValueIgnoreMutation(oldValue); return; } if (!newValue || !this.elementAttached) return; this.view.createGuest(); }; // ----------------------------------------------------------------------------- // Sets up all of the extensionoptions attributes. ExtensionOptionsImpl.prototype.setupAttributes = function() { this.attributes[ExtensionOptionsConstants.ATTRIBUTE_EXTENSION] = new ExtensionAttribute(this); }; // 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 module contains constants used in extensionoptions. // Container for the extensionview constants. var ExtensionOptionsConstants = { // Attributes. ATTRIBUTE_EXTENSION: 'extension' }; exports.$set('ExtensionOptionsConstants', $Object.freeze(ExtensionOptionsConstants)); // 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 CreateEvent = require('guestViewEvents').CreateEvent; var GuestViewEvents = require('guestViewEvents').GuestViewEvents; function ExtensionOptionsEvents(extensionOptionsImpl) { GuestViewEvents.call(this, extensionOptionsImpl); // |setupEventProperty| is normally called automatically, but the // 'createfailed' event is registered here because the event is fired from // ExtensionOptionsImpl instead of in response to an extension event. this.setupEventProperty('createfailed'); } ExtensionOptionsEvents.prototype.__proto__ = GuestViewEvents.prototype; // A dictionary of extension events to be listened for. This // dictionary augments |GuestViewEvents.EVENTS| in guest_view_events.js. See the // documentation there for details. ExtensionOptionsEvents.EVENTS = { 'close': { evt: CreateEvent('extensionOptionsInternal.onClose') }, 'load': { evt: CreateEvent('extensionOptionsInternal.onLoad') }, 'preferredsizechanged': { evt: CreateEvent('extensionOptionsInternal.onPreferredSizeChanged'), fields:['width', 'height'] } } ExtensionOptionsEvents.prototype.getEvents = function() { return ExtensionOptionsEvents.EVENTS; }; // Exports. exports.$set('ExtensionOptionsEvents', ExtensionOptionsEvents); // 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 module implements the ExtensionView . var GuestViewContainer = require('guestViewContainer').GuestViewContainer; var ExtensionViewConstants = require('extensionViewConstants').ExtensionViewConstants; var ExtensionViewEvents = require('extensionViewEvents').ExtensionViewEvents; var ExtensionViewInternal = getInternalApi ? getInternalApi('extensionViewInternal') : require('extensionViewInternal').ExtensionViewInternal; function ExtensionViewImpl(extensionviewElement) { GuestViewContainer.call(this, extensionviewElement, 'extensionview'); // A queue of objects in the order they should be loaded. // Every load call will add the given src, as well as the resolve and reject // functions. Each src will be loaded in the order they were called. this.loadQueue = []; // The current src that is loading. // @type {Object} this.pendingLoad = null; new ExtensionViewEvents(this, this.viewInstanceId); } ExtensionViewImpl.prototype.__proto__ = GuestViewContainer.prototype; ExtensionViewImpl.VIEW_TYPE = 'ExtensionView'; ExtensionViewImpl.setupElement = function(proto) { var apiMethods = ExtensionViewImpl.getApiMethods(); GuestViewContainer.forwardApiMethods(proto, apiMethods); }; ExtensionViewImpl.prototype.createGuest = function(callback) { this.guest.create(this.buildParams(), $Function.bind(function() { this.attachWindow$(); callback(); }, this)); }; ExtensionViewImpl.prototype.buildContainerParams = function() { var params = {}; for (var i in this.attributes) { params[i] = this.attributes[i].getValue(); } return params; }; ExtensionViewImpl.prototype.onElementDetached = function() { this.guest.destroy(); // Reset all attributes. for (var i in this.attributes) { this.attributes[i].setValueIgnoreMutation(); } }; // Updates src upon loadcommit. ExtensionViewImpl.prototype.onLoadCommit = function(url) { this.attributes[ExtensionViewConstants.ATTRIBUTE_SRC]. setValueIgnoreMutation(url); }; // Loads the next pending src from |loadQueue| to the extensionview. ExtensionViewImpl.prototype.loadNextSrc = function() { // If extensionview isn't currently loading a src, load the next src // in |loadQueue|. Otherwise, do nothing. if (!this.pendingLoad && this.loadQueue.length) { this.pendingLoad = $Array.shift(this.loadQueue); var src = this.pendingLoad.src; var resolve = this.pendingLoad.resolve; var reject = this.pendingLoad.reject; // The extensionview validates the |src| twice, once in |parseSrc| and then // in |loadSrc|. The |src| isn't checked directly in |loadNextSrc| for // validity since the sending renderer (WebUI) is trusted. ExtensionViewInternal.parseSrc( src, $Function.bind(function(isSrcValid, extensionId) { // Check if the src is valid. if (!isSrcValid) { reject('Failed to load: src is not valid.'); return; } // Destroy the current guest and create a new one if extension ID // is different. // // This may happen if the extensionview is loads an extension page, and // is then intended to load a page served from a different extension in // the same part of the WebUI. // // The two calls may look like the following: // extensionview.load('chrome-extension://firstId/page.html'); // extensionview.load('chrome-extension://secondId/page.html'); // The second time load is called, we destroy the current guest since // we will be loading content from a different extension. if (extensionId != this.attributes[ExtensionViewConstants.ATTRIBUTE_EXTENSION] .getValue()) { this.guest.destroy(); // Update the extension and src attributes. this.attributes[ExtensionViewConstants.ATTRIBUTE_EXTENSION] .setValueIgnoreMutation(extensionId); this.attributes[ExtensionViewConstants.ATTRIBUTE_SRC] .setValueIgnoreMutation(src); this.createGuest($Function.bind(function() { if (this.guest.getId() <= 0) { reject('Failed to load: guest creation failed.'); } else { resolve('Successful load.'); } }, this)); } else { ExtensionViewInternal.loadSrc(this.guest.getId(), src, $Function.bind(function(hasLoadSucceeded) { if (!hasLoadSucceeded) { reject('Failed to load.'); } else { // Update the src attribute. this.attributes[ExtensionViewConstants.ATTRIBUTE_SRC] .setValueIgnoreMutation(src); resolve('Successful load.'); } }, this)); } }, this)); } }; GuestViewContainer.registerElement(ExtensionViewImpl); // Exports. exports.$set('ExtensionViewImpl', ExtensionViewImpl); // 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 module implements the public-facing API functions for the // tag. var ExtensionViewInternal = getInternalApi ? getInternalApi('extensionViewInternal') : require('extensionViewInternal').ExtensionViewInternal; var ExtensionViewImpl = require('extensionView').ExtensionViewImpl; var ExtensionViewConstants = require('extensionViewConstants').ExtensionViewConstants; // An array of 's public-facing API methods. var EXTENSION_VIEW_API_METHODS = [ // Loads the given src into extensionview. Must be called every time the // the extensionview should load a new page. This is the only way to set // the extension and src attributes. Returns a promise indicating whether // or not load was successful. 'load' ]; // ----------------------------------------------------------------------------- // Custom API method implementations. ExtensionViewImpl.prototype.load = function(src) { return new Promise($Function.bind(function(resolve, reject) { $Array.push(this.loadQueue, {src: src, resolve: resolve, reject: reject}); this.loadNextSrc(); }, this)) .then($Function.bind(function onLoadResolved() { this.pendingLoad = null; this.loadNextSrc(); }, this), $Function.bind(function onLoadRejected(reason) { this.pendingLoad = null; this.loadNextSrc(); return Promise.reject(reason); }, this)); }; // ----------------------------------------------------------------------------- ExtensionViewImpl.getApiMethods = function() { return EXTENSION_VIEW_API_METHODS; }; // 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 module implements the attributes of the tag. var GuestViewAttributes = require('guestViewAttributes').GuestViewAttributes; var ExtensionViewConstants = require('extensionViewConstants').ExtensionViewConstants; var ExtensionViewImpl = require('extensionView').ExtensionViewImpl; var ExtensionViewInternal = getInternalApi ? getInternalApi('extensionViewInternal') : require('extensionViewInternal').ExtensionViewInternal; // ----------------------------------------------------------------------------- // ExtensionAttribute object. // Attribute that handles the extension associated with the extensionview. function ExtensionAttribute(view) { GuestViewAttributes.ReadOnlyAttribute.call( this, ExtensionViewConstants.ATTRIBUTE_EXTENSION, view); } ExtensionAttribute.prototype.__proto__ = GuestViewAttributes.ReadOnlyAttribute.prototype; // ----------------------------------------------------------------------------- // SrcAttribute object. // Attribute that handles the location and navigation of the extensionview. // This is read only because we only want to be able to navigate to a src // through the load API call, which checks for URL validity and the extension // ID of the new src. function SrcAttribute(view) { GuestViewAttributes.ReadOnlyAttribute.call( this, ExtensionViewConstants.ATTRIBUTE_SRC, view); } SrcAttribute.prototype.__proto__ = GuestViewAttributes.ReadOnlyAttribute.prototype; SrcAttribute.prototype.handleMutation = function(oldValue, newValue) { console.log('src is read only. Use .load(url) to navigate to a new ' + 'extension page.'); this.setValueIgnoreMutation(oldValue); } // ----------------------------------------------------------------------------- // Sets up all of the extensionview attributes. ExtensionViewImpl.prototype.setupAttributes = function() { this.attributes[ExtensionViewConstants.ATTRIBUTE_EXTENSION] = new ExtensionAttribute(this); this.attributes[ExtensionViewConstants.ATTRIBUTE_SRC] = new SrcAttribute(this); }; // 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 module contains constants used in extensionview. // Container for the extensionview constants. var ExtensionViewConstants = { // Attributes. ATTRIBUTE_EXTENSION: 'extension', ATTRIBUTE_SRC: 'src', }; exports.$set('ExtensionViewConstants', $Object.freeze(ExtensionViewConstants)); // 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. // Event management for ExtensionView. var CreateEvent = require('guestViewEvents').CreateEvent; var GuestViewEvents = require('guestViewEvents').GuestViewEvents; function ExtensionViewEvents(extensionViewImpl) { GuestViewEvents.call(this, extensionViewImpl); } ExtensionViewEvents.prototype.__proto__ = GuestViewEvents.prototype; ExtensionViewEvents.EVENTS = { 'loadcommit': { evt: CreateEvent('extensionViewInternal.onLoadCommit'), handler: 'handleLoadCommitEvent', internal: true } }; ExtensionViewEvents.prototype.getEvents = function() { return ExtensionViewEvents.EVENTS; }; ExtensionViewEvents.prototype.handleLoadCommitEvent = function(event) { this.view.onLoadCommit(event.url); }; exports.$set('ExtensionViewEvents', ExtensionViewEvents); // 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. if (!apiBridge) { exports.$set( 'ExtensionViewInternal', require('binding').Binding.create('extensionViewInternal').generate()); } // 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 module implements the base attributes of the GuestView tags. // ----------------------------------------------------------------------------- // Attribute object. // Default implementation of a GuestView attribute. function Attribute(name, view) { this.dirty = false; this.ignoreMutation = false; this.name = name; this.view = view; this.defineProperty(); } // Prevent GuestViewEvents inadvertently inheritng code from the global Object, // allowing a pathway for unintended execution of user code. // TODO(wjmaclean): Use utils.expose() here instead, track down other issues // of Object inheritance. https://crbug.com/701034 Attribute.prototype.__proto__ = null; // Retrieves and returns the attribute's value. Attribute.prototype.getValue = function() { return this.view.element.getAttribute(this.name) || ''; }; // Retrieves and returns the attribute's value if it has been dirtied since // the last time this method was called. Returns null otherwise. Attribute.prototype.getValueIfDirty = function() { if (!this.dirty) return null; this.dirty = false; return this.getValue(); }; // Sets the attribute's value. Attribute.prototype.setValue = function(value) { this.view.element.setAttribute(this.name, value || ''); }; // Changes the attribute's value without triggering its mutation handler. Attribute.prototype.setValueIgnoreMutation = function(value) { this.ignoreMutation = true; this.setValue(value); this.ignoreMutation = false; }; // Defines this attribute as a property on the view's element. Attribute.prototype.defineProperty = function() { $Object.defineProperty(this.view.element, this.name, { get: $Function.bind(function() { return this.getValue(); }, this), set: $Function.bind(function(value) { this.setValue(value); }, this), enumerable: true }); }; // Called when the attribute's value changes. Attribute.prototype.maybeHandleMutation = function(oldValue, newValue) { if (this.ignoreMutation) return; this.dirty = true; this.handleMutation(oldValue, newValue); }; // Called when a change that isn't ignored occurs to the attribute's value. Attribute.prototype.handleMutation = function(oldValue, newValue) {}; // Called when the view's element is attached to the DOM tree. Attribute.prototype.attach = function() {}; // Called when the view's element is detached from the DOM tree. Attribute.prototype.detach = function() {}; // ----------------------------------------------------------------------------- // BooleanAttribute object. // An attribute that is treated as a Boolean. function BooleanAttribute(name, view) { Attribute.call(this, name, view); } BooleanAttribute.prototype.__proto__ = Attribute.prototype; BooleanAttribute.prototype.getValue = function() { return this.view.element.hasAttribute(this.name); }; BooleanAttribute.prototype.setValue = function(value) { if (!value) { this.view.element.removeAttribute(this.name); } else { this.view.element.setAttribute(this.name, ''); } }; // ----------------------------------------------------------------------------- // IntegerAttribute object. // An attribute that is treated as an integer. function IntegerAttribute(name, view) { Attribute.call(this, name, view); } IntegerAttribute.prototype.__proto__ = Attribute.prototype; IntegerAttribute.prototype.getValue = function() { return parseInt(this.view.element.getAttribute(this.name)) || 0; }; IntegerAttribute.prototype.setValue = function(value) { this.view.element.setAttribute(this.name, parseInt(value) || 0); }; // ----------------------------------------------------------------------------- // ReadOnlyAttribute object. // An attribute that cannot be changed (externally). The only way to set it // internally is via |setValueIgnoreMutation|. function ReadOnlyAttribute(name, view) { Attribute.call(this, name, view); } ReadOnlyAttribute.prototype.__proto__ = Attribute.prototype; ReadOnlyAttribute.prototype.handleMutation = function(oldValue, newValue) { this.setValueIgnoreMutation(oldValue); } // ----------------------------------------------------------------------------- var GuestViewAttributes = { Attribute: Attribute, BooleanAttribute: BooleanAttribute, IntegerAttribute: IntegerAttribute, ReadOnlyAttribute: ReadOnlyAttribute }; // Exports. exports.$set('GuestViewAttributes', GuestViewAttributes); // 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. // This module implements the shared functionality for different guestview // containers, such as web_view, app_view, etc. var DocumentNatives = requireNative('document_natives'); var GuestView = require('guestView').GuestView; var GuestViewInternalNatives = requireNative('guest_view_internal'); var IdGenerator = requireNative('id_generator'); var MessagingNatives = requireNative('messaging_natives'); function GuestViewContainer(element, viewType) { privates(element).internal = this; this.attributes = {}; this.element = element; this.elementAttached = false; this.viewInstanceId = IdGenerator.GetNextId(); this.viewType = viewType; this.setupGuestProperty(); this.guest = new GuestView(viewType); this.setupAttributes(); privates(this).internalElement = this.createInternalElement$(); this.setupFocusPropagation(); var shadowRoot = this.element.createShadowRoot(); shadowRoot.appendChild(privates(this).internalElement); GuestViewInternalNatives.RegisterView(this.viewInstanceId, this, viewType); } // Prevent GuestViewContainer inadvertently inheriting code from the global // Object, allowing a pathway for executing unintended user code execution. // TODO(wjmaclean): Use utils.expose() here instead? Track down other issues // of Object inheritance. https://crbug.com/701034 GuestViewContainer.prototype.__proto__ = null; // Forward public API methods from |proto| to their internal implementations. GuestViewContainer.forwardApiMethods = function(proto, apiMethods) { var createProtoHandler = function(m) { return function(var_args) { var internal = privates(this).internal; return $Function.apply(internal[m], internal, arguments); }; }; for (var i = 0; apiMethods[i]; ++i) { proto[apiMethods[i]] = createProtoHandler(apiMethods[i]); } }; // Registers the browserplugin and guestview as custom elements once the // document has loaded. GuestViewContainer.registerElement = function(guestViewContainerType) { var useCapture = true; window.addEventListener('readystatechange', function listener(event) { if (document.readyState == 'loading') return; registerInternalElement(guestViewContainerType.VIEW_TYPE.toLowerCase()); registerGuestViewElement(guestViewContainerType); window.removeEventListener(event.type, listener, useCapture); }, useCapture); }; // Create the 'guest' property to track new GuestViews and always listen for // their resizes. GuestViewContainer.prototype.setupGuestProperty = function() { $Object.defineProperty(this, 'guest', { get: $Function.bind(function() { return privates(this).guest; }, this), set: $Function.bind(function(value) { privates(this).guest = value; if (!value) { return; } privates(this).guest.onresize = $Function.bind(function(e) { // Dispatch the 'contentresize' event. var contentResizeEvent = new Event('contentresize', { bubbles: true }); contentResizeEvent.oldWidth = e.oldWidth; contentResizeEvent.oldHeight = e.oldHeight; contentResizeEvent.newWidth = e.newWidth; contentResizeEvent.newHeight = e.newHeight; this.dispatchEvent(contentResizeEvent); }, this); }, this), enumerable: true }); }; GuestViewContainer.prototype.createInternalElement$ = function() { // We create BrowserPlugin as a custom element in order to observe changes // to attributes synchronously. var browserPluginElement = new GuestViewContainer[this.viewType + 'BrowserPlugin'](); privates(browserPluginElement).internal = this; return browserPluginElement; }; GuestViewContainer.prototype.setupFocusPropagation = function() { if (!this.element.hasAttribute('tabIndex')) { // GuestViewContainer needs a tabIndex in order to be focusable. // TODO(fsamuel): It would be nice to avoid exposing a tabIndex attribute // to allow GuestViewContainer to be focusable. // See http://crbug.com/231664. this.element.setAttribute('tabIndex', -1); } }; GuestViewContainer.prototype.focus = function() { // Focus the internal element when focus() is called on the GuestView element. privates(this).internalElement.focus(); } GuestViewContainer.prototype.attachWindow$ = function() { if (!this.internalInstanceId) { return true; } this.guest.attach(this.internalInstanceId, this.viewInstanceId, this.buildParams()); return true; }; GuestViewContainer.prototype.makeGCOwnContainer = function(internalInstanceId) { MessagingNatives.BindToGC(this, function() { GuestViewInternalNatives.DestroyContainer(internalInstanceId); }, -1); }; GuestViewContainer.prototype.onInternalInstanceId = function( internalInstanceId) { this.internalInstanceId = internalInstanceId; this.makeGCOwnContainer(this.internalInstanceId); // Track when the element resizes using the element resize callback. GuestViewInternalNatives.RegisterElementResizeCallback( this.internalInstanceId, this.weakWrapper(this.onElementResize)); if (!this.guest.getId()) { return; } this.guest.attach(this.internalInstanceId, this.viewInstanceId, this.buildParams()); }; GuestViewContainer.prototype.handleInternalElementAttributeMutation = function(name, oldValue, newValue) { if (name == 'internalinstanceid' && !oldValue && !!newValue) { privates(this).internalElement.removeAttribute('internalinstanceid'); this.onInternalInstanceId(parseInt(newValue)); } }; GuestViewContainer.prototype.onElementResize = function(newWidth, newHeight) { if (!this.guest.getId()) return; this.guest.setSize({normal: {width: newWidth, height: newHeight}}); }; GuestViewContainer.prototype.buildParams = function() { var params = this.buildContainerParams(); params['instanceId'] = this.viewInstanceId; // When the GuestViewContainer is not participating in layout (display:none) // then getBoundingClientRect() would report a width and height of 0. // However, in the case where the GuestViewContainer has a fixed size we can // use that value to initially size the guest so as to avoid a relayout of the // on display:block. var css = window.getComputedStyle(this.element, null); var elementRect = this.element.getBoundingClientRect(); params['elementWidth'] = parseInt(elementRect.width) || parseInt(css.getPropertyValue('width')); params['elementHeight'] = parseInt(elementRect.height) || parseInt(css.getPropertyValue('height')); return params; }; GuestViewContainer.prototype.dispatchEvent = function(event) { return this.element.dispatchEvent(event); } // Returns a wrapper function for |func| with a weak reference to |this|. GuestViewContainer.prototype.weakWrapper = function(func) { var viewInstanceId = this.viewInstanceId; return function() { var view = GuestViewInternalNatives.GetViewFromID(viewInstanceId); if (view) { return $Function.apply(func, view, $Array.slice(arguments)); } }; }; // Implemented by the specific view type, if needed. GuestViewContainer.prototype.buildContainerParams = function() { return {}; }; GuestViewContainer.prototype.willAttachElement = function() {}; GuestViewContainer.prototype.onElementAttached = function() {}; GuestViewContainer.prototype.onElementDetached = function() {}; GuestViewContainer.prototype.setupAttributes = function() {}; // Registers the browser plugin custom element. |viewType| is the // name of the specific guestview container (e.g. 'webview'). function registerInternalElement(viewType) { var proto = $Object.create(HTMLElement.prototype); proto.createdCallback = function() { this.setAttribute('type', 'application/browser-plugin'); this.setAttribute('id', 'browser-plugin-' + IdGenerator.GetNextId()); this.style.width = '100%'; this.style.height = '100%'; }; proto.attachedCallback = function() { // Load the plugin immediately. var unused = this.nonExistentAttribute; }; proto.attributeChangedCallback = function(name, oldValue, newValue) { var internal = privates(this).internal; if (!internal) { return; } internal.handleInternalElementAttributeMutation(name, oldValue, newValue); }; GuestViewContainer[viewType + 'BrowserPlugin'] = DocumentNatives.RegisterElement(viewType + 'browserplugin', {extends: 'object', prototype: proto}); delete proto.createdCallback; delete proto.attachedCallback; delete proto.detachedCallback; delete proto.attributeChangedCallback; }; // Registers the guestview container as a custom element. // |guestViewContainerType| is the type of guestview container // (e.g. WebViewImpl). function registerGuestViewElement(guestViewContainerType) { var proto = $Object.create(HTMLElement.prototype); proto.createdCallback = function() { new guestViewContainerType(this); }; proto.attachedCallback = function() { var internal = privates(this).internal; if (!internal) { return; } internal.elementAttached = true; internal.willAttachElement(); internal.onElementAttached(); }; proto.attributeChangedCallback = function(name, oldValue, newValue) { var internal = privates(this).internal; if (!internal || !internal.attributes[name]) { return; } // Let the changed attribute handle its own mutation. internal.attributes[name].maybeHandleMutation(oldValue, newValue); }; proto.detachedCallback = function() { var internal = privates(this).internal; if (!internal) { return; } internal.elementAttached = false; internal.internalInstanceId = 0; internal.guest.destroy(); internal.onElementDetached(); }; // Override |focus| to let |internal| handle it. proto.focus = function() { var internal = privates(this).internal; if (!internal) { return; } internal.focus(); }; // Let the specific view type add extra functionality to its custom element // through |proto|. if (guestViewContainerType.setupElement) { guestViewContainerType.setupElement(proto); } window[guestViewContainerType.VIEW_TYPE] = DocumentNatives.RegisterElement( guestViewContainerType.VIEW_TYPE.toLowerCase(), {prototype: proto}); // Delete the callbacks so developers cannot call them and produce unexpected // behavior. delete proto.createdCallback; delete proto.attachedCallback; delete proto.detachedCallback; delete proto.attributeChangedCallback; } // Exports. exports.$set('GuestViewContainer', GuestViewContainer); // 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 module implements the registration of guestview elements when // permissions are not available. These elements exist only to provide a useful // error message when developers attempt to use them. var DocumentNatives = requireNative('document_natives'); var GuestViewContainer = require('guestViewContainer').GuestViewContainer; var ERROR_MESSAGE = 'You do not have permission to use the %1 element.' + ' Be sure to declare the "%1" permission in your manifest file.'; // A list of view types that will have custom elements registered if they are // not already registered by the time this module is loaded. var VIEW_TYPES = [ 'AppView', 'ExtensionOptions', 'ExtensionView', 'WebView' ]; // Registers a GuestView custom element. function registerGuestViewElement(viewType) { var proto = Object.create(HTMLElement.prototype); proto.createdCallback = function() { window.console.error(ERROR_MESSAGE.replace(/%1/g, viewType.toLowerCase())); }; window[viewType] = DocumentNatives.RegisterElement(viewType.toLowerCase(), {prototype: proto}); // Delete the callbacks so developers cannot call them and produce unexpected // behavior. delete proto.createdCallback; delete proto.attachedCallback; delete proto.detachedCallback; delete proto.attributeChangedCallback; } var useCapture = true; window.addEventListener('readystatechange', function listener(event) { if (document.readyState == 'loading') return; for (var i = 0; i != VIEW_TYPES.length; ++i) { // Register the error-providing custom element only for those view types // that have not already been registered. Since this module is always loaded // last, all the view types that are available (i.e. have the proper // permissions) will have already been registered on |window|. if (!window[VIEW_TYPES[i]]) registerGuestViewElement(VIEW_TYPES[i]); } window.removeEventListener(event.type, listener, useCapture); }, useCapture); // 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. // Event management for GuestViewContainers. var GuestViewInternalNatives = requireNative('guest_view_internal'); var MessagingNatives = requireNative('messaging_natives'); var EventBindings; var CreateEvent = function(name) { if (bindingUtil) { return bindingUtil.createCustomEvent(name, null, true /* supportsFilters */, false /* supportsLazyListeners */); } var eventOpts = { __proto__: null, supportsListeners: true, supportsFilters: true, // GuestView-related events never support lazy listeners. supportsLazyListeners: false, }; if (!EventBindings) EventBindings = require('event_bindings'); return new EventBindings.Event(name, undefined, eventOpts); }; function GuestViewEvents(view) { view.events = this; this.view = view; this.on = {}; // |setupEventProperty| is normally called automatically, but these events are // are registered here because they are dispatched from GuestViewContainer // instead of in response to extension events. this.setupEventProperty('contentresize'); this.setupEventProperty('resize'); this.setupEvents(); } // Prevent GuestViewEvents inadvertently inheritng code from the global Object, // allowing a pathway for unintended execution of user code. // TODO(wjmaclean): Use utils.expose() here instead, track down other issues // of Object inheritance. https://crbug.com/701034 GuestViewEvents.prototype.__proto__ = null; // |GuestViewEvents.EVENTS| is a dictionary of extension events to be listened // for, which specifies how each event should be handled. The events are // organized by name, and by default will be dispatched as DOM events with // the same name. // |cancelable| (default: false) specifies whether the DOM event's default // behavior can be canceled. If the default action associated with the event // is prevented, then its dispatch function will return false in its event // handler. The event must have a specified |handler| for this to be // meaningful. // |evt| specifies a descriptor object for the extension event. An event // listener will be attached to this descriptor. // |fields| (default: none) specifies the public-facing fields in the DOM event // that are accessible to developers. // |handler| specifies the name of a handler function to be called each time // that extension event is caught by its event listener. The DOM event // should be dispatched within this handler function (if desired). With no // handler function, the DOM event will be dispatched by default each time // the extension event is caught. // |internal| (default: false) specifies that the event will not be dispatched // as a DOM event, and will also not appear as an on* property on the view’s // element. A |handler| should be specified for all internal events, and // |fields| and |cancelable| should be left unspecified (as they are only // meaningful for DOM events). GuestViewEvents.EVENTS = {}; // Attaches |listener| onto the event descriptor object |evt|, and registers it // to be removed once this GuestViewEvents object is garbage collected. GuestViewEvents.prototype.addScopedListener = function( evt, listener, listenerOpts) { $Array.push(this.listenersToBeRemoved, { 'evt': evt, 'listener': listener }); evt.addListener(listener, listenerOpts); }; // Sets up the handling of events. GuestViewEvents.prototype.setupEvents = function() { // An array of registerd event listeners that should be removed when this // GuestViewEvents is garbage collected. this.listenersToBeRemoved = []; MessagingNatives.BindToGC( this, $Function.bind(function(listenersToBeRemoved) { for (var i = 0; i != listenersToBeRemoved.length; ++i) { listenersToBeRemoved[i].evt.removeListener( listenersToBeRemoved[i].listener); listenersToBeRemoved[i] = null; } }, undefined, this.listenersToBeRemoved), -1 /* portId */); // Set up the GuestView events. for (var eventName in GuestViewEvents.EVENTS) { this.setupEvent(eventName, GuestViewEvents.EVENTS[eventName]); } // Set up the derived view's events. var events = this.getEvents(); for (var eventName in events) { this.setupEvent(eventName, events[eventName]); } }; // Sets up the handling of the |eventName| event. GuestViewEvents.prototype.setupEvent = function(eventName, eventInfo) { if (!eventInfo.internal) { this.setupEventProperty(eventName); } var listenerOpts = { instanceId: this.view.viewInstanceId }; if (eventInfo.handler) { this.addScopedListener(eventInfo.evt, this.weakWrapper(function(e) { this[eventInfo.handler](e, eventName); }), listenerOpts); return; } // Internal events are not dispatched as DOM events. if (eventInfo.internal) { return; } this.addScopedListener(eventInfo.evt, this.weakWrapper(function(e) { var domEvent = this.makeDomEvent(e, eventName); this.view.dispatchEvent(domEvent); }), listenerOpts); }; // Constructs a DOM event based on the info for the |eventName| event provided // in either |GuestViewEvents.EVENTS| or getEvents(). GuestViewEvents.prototype.makeDomEvent = function(event, eventName) { var eventInfo = GuestViewEvents.EVENTS[eventName] || this.getEvents()[eventName]; // Internal events are not dispatched as DOM events. if (eventInfo.internal) { return null; } var details = { bubbles: true }; if (eventInfo.cancelable) { details.cancelable = true; } var domEvent = new Event(eventName, details); if (eventInfo.fields) { $Array.forEach(eventInfo.fields, $Function.bind(function(field) { if (event[field] !== undefined) { domEvent[field] = event[field]; } }, this)); } return domEvent; }; // Adds an 'on' property on the view, which can be used to set/unset // an event handler. GuestViewEvents.prototype.setupEventProperty = function(eventName) { var propertyName = 'on' + eventName.toLowerCase(); $Object.defineProperty(this.view.element, propertyName, { get: $Function.bind(function() { return this.on[propertyName]; }, this), set: $Function.bind(function(value) { if (this.on[propertyName]) { this.view.element.removeEventListener(eventName, this.on[propertyName]); } this.on[propertyName] = value; if (value) { this.view.element.addEventListener(eventName, value); } }, this), enumerable: true }); }; // returns a wrapper for |func| with a weak reference to |this|. GuestViewEvents.prototype.weakWrapper = function(func) { var viewInstanceId = this.view.viewInstanceId; return function() { var view = GuestViewInternalNatives.GetViewFromID(viewInstanceId); if (!view) { return; } return $Function.apply(func, view.events, $Array.slice(arguments)); }; }; // Implemented by the derived event manager, if one exists. GuestViewEvents.prototype.getEvents = function() { return {}; }; // Exports. exports.$set('GuestViewEvents', GuestViewEvents); exports.$set('CreateEvent', CreateEvent); // 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. // --site-per-process overrides for guest_view_container.js var GuestViewContainer = require('guestViewContainer').GuestViewContainer; var IdGenerator = requireNative('id_generator'); GuestViewContainer.prototype.createInternalElement$ = function() { var iframeElement = document.createElement('iframe'); iframeElement.style.width = '100%'; iframeElement.style.height = '100%'; iframeElement.style.border = '0'; privates(iframeElement).internal = this; return iframeElement; }; GuestViewContainer.prototype.attachWindow$ = function() { var generatedId = IdGenerator.GetNextId(); // Generate an instance id for the container. this.onInternalInstanceId(generatedId); return true; }; GuestViewContainer.prototype.willAttachElement = function () { if (this.deferredAttachCallback) { this.deferredAttachCallback(); this.deferredAttachCallback = null; } }; // 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. // --site-per-process overrides for guest_view.js. var GuestView = require('guestView').GuestView; var GuestViewImpl = require('guestView').GuestViewImpl; var GuestViewInternalNatives = requireNative('guest_view_internal'); var ResizeEvent = require('guestView').ResizeEvent; var getIframeContentWindow = function(viewInstanceId) { var view = GuestViewInternalNatives.GetViewFromID(viewInstanceId); if (!view) return null; var internalIframeElement = privates(view).internalElement; if (internalIframeElement) return internalIframeElement.contentWindow; return null; }; // Internal implementation of attach(). GuestViewImpl.prototype.attachImpl$ = function( internalInstanceId, viewInstanceId, attachParams, callback) { var view = GuestViewInternalNatives.GetViewFromID(viewInstanceId); if (!view.elementAttached) { // Defer the attachment until the element is attached. view.deferredAttachCallback = $Function.bind(this.attachImpl$, this, internalInstanceId, viewInstanceId, attachParams, callback); return; }; // Check the current state. if (!this.checkState('attach')) { this.handleCallback(callback); return; } // Callback wrapper function to store the contentWindow from the attachGuest() // callback, handle potential attaching failure, register an automatic detach, // and advance the queue. var callbackWrapper = function(callback, contentWindow) { // Check if attaching failed. contentWindow = getIframeContentWindow(viewInstanceId); if (!contentWindow) { this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; this.internalInstanceId = 0; } else { // Only update the contentWindow if attaching is successful. this.contentWindow = contentWindow; } this.handleCallback(callback); }; attachParams['instanceId'] = viewInstanceId; var contentWindow = getIframeContentWindow(viewInstanceId); // |contentWindow| is used to retrieve the RenderFrame in cpp. GuestViewInternalNatives.AttachIframeGuest( internalInstanceId, this.id, attachParams, contentWindow, $Function.bind(callbackWrapper, this, callback)); this.internalInstanceId = internalInstanceId; this.state = GuestViewImpl.GuestState.GUEST_STATE_ATTACHED; // Detach automatically when the container is destroyed. GuestViewInternalNatives.RegisterDestructionCallback( internalInstanceId, this.weakWrapper(function() { if (this.state != GuestViewImpl.GuestState.GUEST_STATE_ATTACHED || this.internalInstanceId != internalInstanceId) { return; } this.internalInstanceId = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; }, viewInstanceId)); }; // Internal implementation of create(). GuestViewImpl.prototype.createImpl$ = function(createParams, callback) { // Check the current state. if (!this.checkState('create')) { this.handleCallback(callback); return; } // Callback wrapper function to store the guestInstanceId from the // createGuest() callback, handle potential creation failure, and advance the // queue. var callbackWrapper = function(callback, guestInfo) { this.id = guestInfo.id; // Check if creation failed. if (this.id === 0) { this.state = GuestViewImpl.GuestState.GUEST_STATE_START; this.contentWindow = null; } ResizeEvent.addListener(this.callOnResize, {instanceId: this.id}); this.handleCallback(callback); }; this.sendCreateRequest( createParams, $Function.bind(callbackWrapper, this, callback)); this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; }; // Internal implementation of destroy(). GuestViewImpl.prototype.destroyImpl = function(callback) { // Check the current state. if (!this.checkState('destroy')) { this.handleCallback(callback); return; } if (this.state == GuestViewImpl.GuestState.GUEST_STATE_START) { // destroy() does nothing in this case. this.handleCallback(callback); return; } // If this guest is attached, then detach it first. if (!!this.internalInstanceId) { GuestViewInternalNatives.DetachGuest(this.internalInstanceId); } // Reset the state of the destroyed guest; this.contentWindow = null; this.id = 0; this.internalInstanceId = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_START; if (ResizeEvent.hasListener(this.callOnResize)) { ResizeEvent.removeListener(this.callOnResize); } // Handle callback at end to avoid handling items in the action queue out of // order, since the callback is run synchronously here. this.handleCallback(callback); }; // 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. // This module implements a wrapper for a guestview that manages its // creation, attaching, and destruction. var CreateEvent = require('guestViewEvents').CreateEvent; var GuestViewInternal = getInternalApi ? getInternalApi('guestViewInternal') : require('binding').Binding.create('guestViewInternal').generate(); var GuestViewInternalNatives = requireNative('guest_view_internal'); // Events. var ResizeEvent = CreateEvent('guestViewInternal.onResize'); // Error messages. var ERROR_MSG_ALREADY_ATTACHED = 'The guest has already been attached.'; var ERROR_MSG_ALREADY_CREATED = 'The guest has already been created.'; var ERROR_MSG_INVALID_STATE = 'The guest is in an invalid state.'; var ERROR_MSG_NOT_ATTACHED = 'The guest is not attached.'; var ERROR_MSG_NOT_CREATED = 'The guest has not been created.'; // Properties. var PROPERTY_ON_RESIZE = 'onresize'; // Contains and hides the internal implementation details of |GuestView|, // including maintaining its state and enforcing the proper usage of its API // fucntions. function GuestViewImpl(guestView, viewType, guestInstanceId) { if (guestInstanceId) { this.id = guestInstanceId; this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; } else { this.id = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_START; } this.actionQueue = []; this.contentWindow = null; this.guestView = guestView; this.pendingAction = null; this.viewType = viewType; this.internalInstanceId = 0; this.setupOnResize(); } // Prevent GuestViewImpl inadvertently inheriting code from the global Object, // allowing a pathway for executing unintended user code execution. // TODO(wjmaclean): Use utils.expose() here instead? Track down other issues // of Object inheritance. https://crbug.com/701034 GuestViewImpl.prototype.__proto__ = null; // Possible states. GuestViewImpl.GuestState = { GUEST_STATE_START: 0, GUEST_STATE_CREATED: 1, GUEST_STATE_ATTACHED: 2 }; // Sets up the onResize property on the GuestView. GuestViewImpl.prototype.setupOnResize = function() { $Object.defineProperty(this.guestView, PROPERTY_ON_RESIZE, { get: $Function.bind(function() { return this[PROPERTY_ON_RESIZE]; }, this), set: $Function.bind(function(value) { this[PROPERTY_ON_RESIZE] = value; }, this), enumerable: true }); this.callOnResize = $Function.bind(function(e) { if (!this[PROPERTY_ON_RESIZE]) { return; } this[PROPERTY_ON_RESIZE](e); }, this); }; // Callback wrapper that is used to call the callback of the pending action (if // one exists), and then performs the next action in the queue. GuestViewImpl.prototype.handleCallback = function(callback) { if (callback) { callback(); } this.pendingAction = null; this.performNextAction(); }; // Perform the next action in the queue, if one exists. GuestViewImpl.prototype.performNextAction = function() { // Make sure that there is not already an action in progress, and that there // exists a queued action to perform. if (!this.pendingAction && this.actionQueue.length) { this.pendingAction = $Array.shift(this.actionQueue); this.pendingAction(); } }; // Check the current state to see if the proposed action is valid. Returns false // if invalid. GuestViewImpl.prototype.checkState = function(action) { // Create an error prefix based on the proposed action. var errorPrefix = 'Error calling ' + action + ': '; // Check that the current state is valid. if (!(this.state >= 0 && this.state <= 2)) { window.console.error(errorPrefix + ERROR_MSG_INVALID_STATE); return false; } // Map of possible errors for each action. For each action, the errors are // listed for states in the order: GUEST_STATE_START, GUEST_STATE_CREATED, // GUEST_STATE_ATTACHED. var errors = { 'attach': [ERROR_MSG_NOT_CREATED, null, ERROR_MSG_ALREADY_ATTACHED], 'create': [null, ERROR_MSG_ALREADY_CREATED, ERROR_MSG_ALREADY_CREATED], 'destroy': [null, null, null], 'detach': [ERROR_MSG_NOT_ATTACHED, ERROR_MSG_NOT_ATTACHED, null], 'setSize': [ERROR_MSG_NOT_CREATED, null, null] }; // Check that the proposed action is a real action. if (errors[action] == undefined) { window.console.error(errorPrefix + ERROR_MSG_INVALID_ACTION); return false; } // Report the error if the proposed action is found to be invalid for the // current state. var error; if (error = errors[action][this.state]) { window.console.error(errorPrefix + error); return false; } return true; }; // Returns a wrapper function for |func| with a weak reference to |this|. This // implementation of weakWrapper() requires a provided |viewInstanceId| since // GuestViewImpl does not store this ID. GuestViewImpl.prototype.weakWrapper = function(func, viewInstanceId) { return function() { var view = GuestViewInternalNatives.GetViewFromID(viewInstanceId); if (view && view.guest) { return $Function.apply(func, privates(view.guest).internal, $Array.slice(arguments)); } }; }; // Internal implementation of attach(). GuestViewImpl.prototype.attachImpl$ = function( internalInstanceId, viewInstanceId, attachParams, callback) { // Check the current state. if (!this.checkState('attach')) { this.handleCallback(callback); return; } // Callback wrapper function to store the contentWindow from the attachGuest() // callback, handle potential attaching failure, register an automatic detach, // and advance the queue. var callbackWrapper = function(callback, contentWindow) { // Check if attaching failed. if (!contentWindow) { this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; this.internalInstanceId = 0; } else { // Only update the contentWindow if attaching is successful. this.contentWindow = contentWindow; } this.handleCallback(callback); }; attachParams['instanceId'] = viewInstanceId; GuestViewInternalNatives.AttachGuest( internalInstanceId, this.id, attachParams, $Function.bind(callbackWrapper, this, callback)); this.internalInstanceId = internalInstanceId; this.state = GuestViewImpl.GuestState.GUEST_STATE_ATTACHED; // Detach automatically when the container is destroyed. GuestViewInternalNatives.RegisterDestructionCallback( internalInstanceId, this.weakWrapper(function() { if (this.state != GuestViewImpl.GuestState.GUEST_STATE_ATTACHED || this.internalInstanceId != internalInstanceId) { return; } this.internalInstanceId = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; }, viewInstanceId)); }; // Internal implementation of create(). GuestViewImpl.prototype.createImpl$ = function(createParams, callback) { // Check the current state. if (!this.checkState('create')) { this.handleCallback(callback); return; } // Callback wrapper function to store the guestInstanceId from the // createGuest() callback, handle potential creation failure, and advance the // queue. var callbackWrapper = function(callback, guestInfo) { this.id = guestInfo.id; this.contentWindow = GuestViewInternalNatives.GetContentWindow(guestInfo.contentWindowId); // Check if creation failed. if (this.id === 0) { this.state = GuestViewImpl.GuestState.GUEST_STATE_START; this.contentWindow = null; } ResizeEvent.addListener(this.callOnResize, {instanceId: this.id}); this.handleCallback(callback); }; this.sendCreateRequest(createParams, $Function.bind(callbackWrapper, this, callback)); this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; }; GuestViewImpl.prototype.sendCreateRequest = function( createParams, boundCallback) { GuestViewInternal.createGuest(this.viewType, createParams, boundCallback); }; // Internal implementation of destroy(). GuestViewImpl.prototype.destroyImpl = function(callback) { // Check the current state. if (!this.checkState('destroy')) { this.handleCallback(callback); return; } if (this.state == GuestViewImpl.GuestState.GUEST_STATE_START) { // destroy() does nothing in this case. this.handleCallback(callback); return; } // If this guest is attached, then detach it first. if (!!this.internalInstanceId) { GuestViewInternalNatives.DetachGuest(this.internalInstanceId); } GuestViewInternal.destroyGuest( this.id, $Function.bind(this.handleCallback, this, callback)); // Reset the state of the destroyed guest; it's ok to do this after shipping // the callback to the GuestViewInternal api, since it runs asynchronously, // and the changes below will happen before the next item from the action // queue is executed. this.contentWindow = null; this.id = 0; this.internalInstanceId = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_START; if (ResizeEvent.hasListener(this.callOnResize)) { ResizeEvent.removeListener(this.callOnResize); } }; // Internal implementation of detach(). GuestViewImpl.prototype.detachImpl = function(callback) { // Check the current state. if (!this.checkState('detach')) { this.handleCallback(callback); return; } GuestViewInternalNatives.DetachGuest( this.internalInstanceId, $Function.bind(this.handleCallback, this, callback)); this.internalInstanceId = 0; this.state = GuestViewImpl.GuestState.GUEST_STATE_CREATED; }; // Internal implementation of setSize(). GuestViewImpl.prototype.setSizeImpl = function(sizeParams, callback) { // Check the current state. if (!this.checkState('setSize')) { this.handleCallback(callback); return; } GuestViewInternal.setSize( this.id, sizeParams, $Function.bind(this.handleCallback, this, callback)); }; // The exposed interface to a guestview. Exposes in its API the functions // attach(), create(), destroy(), and getId(). All other implementation details // are hidden. function GuestView(viewType, guestInstanceId) { privates(this).internal = new GuestViewImpl(this, viewType, guestInstanceId); } // Attaches the guestview to the container with ID |internalInstanceId|. GuestView.prototype.attach = function( internalInstanceId, viewInstanceId, attachParams, callback) { var internal = privates(this).internal; $Array.push(internal.actionQueue, $Function.bind(internal.attachImpl$, internal, internalInstanceId, viewInstanceId, attachParams, callback)); internal.performNextAction(); }; // Creates the guestview. GuestView.prototype.create = function(createParams, callback) { var internal = privates(this).internal; $Array.push(internal.actionQueue, $Function.bind(internal.createImpl$, internal, createParams, callback)); internal.performNextAction(); }; // Destroys the guestview. Nothing can be done with the guestview after it has // been destroyed. GuestView.prototype.destroy = function(callback) { var internal = privates(this).internal; $Array.push(internal.actionQueue, $Function.bind(internal.destroyImpl, internal, callback)); internal.performNextAction(); }; // Detaches the guestview from its container. // Note: This is not currently used. GuestView.prototype.detach = function(callback) { var internal = privates(this).internal; $Array.push(internal.actionQueue, $Function.bind(internal.detachImpl, internal, callback)); internal.performNextAction(); }; // Adjusts the guestview's sizing parameters. GuestView.prototype.setSize = function(sizeParams, callback) { var internal = privates(this).internal; $Array.push(internal.actionQueue, $Function.bind(internal.setSizeImpl, internal, sizeParams, callback)); internal.performNextAction(); }; // Returns the contentWindow for this guestview. GuestView.prototype.getContentWindow = function() { var internal = privates(this).internal; return internal.contentWindow; }; // Returns the ID for this guestview. GuestView.prototype.getId = function() { var internal = privates(this).internal; return internal.id; }; // Exports if (!apiBridge) { exports.$set('GuestView', GuestView); exports.$set('GuestViewImpl', GuestViewImpl); exports.$set('ResizeEvent', ResizeEvent); } // 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. // This function takes an object |imageSpec| with the key |path| - // corresponding to the internet URL to be translated - and optionally // |width| and |height| which are the maximum dimensions to be used when // converting the image. function loadImageData(imageSpec, callbacks) { var path = imageSpec.path; var img = new Image(); if (typeof callbacks.onerror === 'function') { img.onerror = function() { callbacks.onerror({ problem: 'could_not_load', path: path }); }; } img.onload = function() { var canvas = document.createElement('canvas'); if (img.width <= 0 || img.height <= 0) { callbacks.onerror({ problem: 'image_size_invalid', path: path}); return; } var scaleFactor = 1; if (imageSpec.width && imageSpec.width < img.width) scaleFactor = imageSpec.width / img.width; if (imageSpec.height && imageSpec.height < img.height) { var heightScale = imageSpec.height / img.height; if (heightScale < scaleFactor) scaleFactor = heightScale; } canvas.width = img.width * scaleFactor; canvas.height = img.height * scaleFactor; var canvas_context = canvas.getContext('2d'); canvas_context.clearRect(0, 0, canvas.width, canvas.height); canvas_context.drawImage(img, 0, 0, canvas.width, canvas.height); try { var imageData = canvas_context.getImageData( 0, 0, canvas.width, canvas.height); if (typeof callbacks.oncomplete === 'function') { callbacks.oncomplete( imageData.width, imageData.height, imageData.data.buffer); } } catch (e) { if (typeof callbacks.onerror === 'function') { callbacks.onerror({ problem: 'data_url_unavailable', path: path }); } } } img.src = path; } function on_complete_index(index, err, loading, finished, callbacks) { return function(width, height, imageData) { delete loading[index]; finished[index] = { width: width, height: height, data: imageData }; if (err) callbacks.onerror(index); if ($Object.keys(loading).length == 0) callbacks.oncomplete(finished); } } function loadAllImages(imageSpecs, callbacks) { var loading = {}, finished = [], index, pathname; for (var index = 0; index < imageSpecs.length; index++) { loading[index] = imageSpecs[index]; loadImageData(imageSpecs[index], { oncomplete: on_complete_index(index, false, loading, finished, callbacks), onerror: on_complete_index(index, true, loading, finished, callbacks) }); } } exports.$set('loadImageData', loadImageData); exports.$set('loadAllImages', loadAllImages); // 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. // ----------------------------------------------------------------------------- // NOTE: If you change this file you need to touch // extension_renderer_resources.grd to have your change take effect. // ----------------------------------------------------------------------------- //============================================================================== // This file contains a class that implements a subset of JSON Schema. // See: http://www.json.com/json-schema-proposal/ for more details. // // The following features of JSON Schema are not implemented: // - requires // - unique // - disallow // - union types (but replaced with 'choices') // // The following properties are not applicable to the interface exposed by // this class: // - options // - readonly // - title // - description // - format // - default // - transient // - hidden // // There are also these departures from the JSON Schema proposal: // - function and undefined types are supported // - null counts as 'unspecified' for optional values // - added the 'choices' property, to allow specifying a list of possible types // for a value // - by default an "object" typed schema does not allow additional properties. // if present, "additionalProperties" is to be a schema against which all // additional properties will be validated. //============================================================================== var utils = require('utils'); var loggingNative = requireNative('logging'); var schemaRegistry = requireNative('schema_registry'); var CHECK = loggingNative.CHECK; var DCHECK = loggingNative.DCHECK; var WARNING = loggingNative.WARNING; function loadTypeSchema(typeName, defaultSchema) { var parts = $String.split(typeName, '.'); if (parts.length == 1) { if (defaultSchema == null) { WARNING('Trying to reference "' + typeName + '" ' + 'with neither namespace nor default schema.'); return null; } var types = defaultSchema.types; } else { var schemaName = $Array.join($Array.slice(parts, 0, parts.length - 1), '.'); var types = schemaRegistry.GetSchema(schemaName).types; } for (var i = 0; i < types.length; ++i) { if (types[i].id == typeName) return types[i]; } return null; } function isInstanceOfClass(instance, className) { while ((instance = instance.__proto__)) { if (instance.constructor.name == className) return true; } return false; } function isOptionalValue(value) { return value === undefined || value === null; } function enumToString(enumValue) { if (enumValue.name === undefined) return enumValue; return enumValue.name; } /** * Validates an instance against a schema and accumulates errors. Usage: * * var validator = new JSONSchemaValidator(); * validator.validate(inst, schema); * if (validator.errors.length == 0) * console.log("Valid!"); * else * console.log(validator.errors); * * The errors property contains a list of objects. Each object has two * properties: "path" and "message". The "path" property contains the path to * the key that had the problem, and the "message" property contains a sentence * describing the error. */ function JSONSchemaValidator() { this.errors = []; this.types = []; } $Object.setPrototypeOf(JSONSchemaValidator.prototype, null); var messages = { __proto__: null, invalidEnum: 'Value must be one of: [*].', propertyRequired: 'Property is required.', unexpectedProperty: 'Unexpected property.', arrayMinItems: 'Array must have at least * items.', arrayMaxItems: 'Array must not have more than * items.', itemRequired: 'Item is required.', stringMinLength: 'String must be at least * characters long.', stringMaxLength: 'String must not be more than * characters long.', stringPattern: 'String must match the pattern: *.', numberFiniteNotNan: 'Value must not be *.', numberMinValue: 'Value must not be less than *.', numberMaxValue: 'Value must not be greater than *.', numberIntValue: 'Value must fit in a 32-bit signed integer.', numberMaxDecimal: 'Value must not have more than * decimal places.', invalidType: "Expected '*' but got '*'.", invalidTypeIntegerNumber: "Expected 'integer' but got 'number', consider using Math.round().", invalidChoice: 'Value does not match any valid type choices.', invalidPropertyType: 'Missing property type.', schemaRequired: 'Schema value required.', unknownSchemaReference: 'Unknown schema reference: *.', notInstance: 'Object must be an instance of *.', }; /** * Builds an error message. Key is the property in the |errors| object, and * |opt_replacements| is an array of values to replace "*" characters with. */ utils.defineProperty(JSONSchemaValidator, 'formatError', function(key, opt_replacements) { var message = messages[key]; if (opt_replacements) { for (var i = 0; i < opt_replacements.length; ++i) { DCHECK($String.indexOf(message, '*') != -1, message); message = $String.replace(message, '*', opt_replacements[i]); } } DCHECK($String.indexOf(message, '*') == -1) return message; }); /** * Classifies a value as one of the JSON schema primitive types. Note that we * don't explicitly disallow 'function', because we want to allow functions in * the input values. */ utils.defineProperty(JSONSchemaValidator, 'getType', function(value) { // If we can determine the type safely in JS, it's fastest to do it here. // However, Object types are difficult to classify, so we have to do it in // C++. var s = typeof value; if (s === 'object') return value === null ? 'null' : schemaRegistry.GetObjectType(value); if (s === 'number') return value % 1 === 0 ? 'integer' : 'number'; return s; }); /** * Add types that may be referenced by validated schemas that reference them * with "$ref": . Each type must be a valid schema and define an * "id" property. */ JSONSchemaValidator.prototype.addTypes = function(typeOrTypeList) { function addType(validator, type) { if (!type.id) throw new Error("Attempt to addType with missing 'id' property"); validator.types[type.id] = type; } if ($Array.isArray(typeOrTypeList)) { for (var i = 0; i < typeOrTypeList.length; ++i) { addType(this, typeOrTypeList[i]); } } else { addType(this, typeOrTypeList); } } /** * Returns a list of strings of the types that this schema accepts. */ JSONSchemaValidator.prototype.getAllTypesForSchema = function(schema) { var schemaTypes = []; if (schema.type) $Array.push(schemaTypes, schema.type); if (schema.choices) { for (var i = 0; i < schema.choices.length; ++i) { var choiceTypes = this.getAllTypesForSchema(schema.choices[i]); schemaTypes = $Array.concat(schemaTypes, choiceTypes); } } var ref = schema['$ref']; if (ref) { var type = this.getOrAddType(ref); CHECK(type, 'Could not find type ' + ref); schemaTypes = $Array.concat(schemaTypes, this.getAllTypesForSchema(type)); } return schemaTypes; }; JSONSchemaValidator.prototype.getOrAddType = function(typeName) { if (!this.types[typeName]) this.types[typeName] = loadTypeSchema(typeName); return this.types[typeName]; }; /** * Returns true if |schema| would accept an argument of type |type|. */ JSONSchemaValidator.prototype.isValidSchemaType = function(type, schema) { if (type == 'any') return true; // TODO(kalman): I don't understand this code. How can type be "null"? if (schema.optional && (type == 'null' || type == 'undefined')) return true; var schemaTypes = this.getAllTypesForSchema(schema); for (var i = 0; i < schemaTypes.length; ++i) { if (schemaTypes[i] == 'any' || type == schemaTypes[i] || (type == 'integer' && schemaTypes[i] == 'number')) return true; } return false; }; /** * Returns true if there is a non-null argument that both |schema1| and * |schema2| would accept. */ JSONSchemaValidator.prototype.checkSchemaOverlap = function(schema1, schema2) { var schema1Types = this.getAllTypesForSchema(schema1); for (var i = 0; i < schema1Types.length; ++i) { if (this.isValidSchemaType(schema1Types[i], schema2)) return true; } return false; }; /** * Validates an instance against a schema. The instance can be any JavaScript * value and will be validated recursively. When this method returns, the * |errors| property will contain a list of errors, if any. */ JSONSchemaValidator.prototype.validate = function(instance, schema, opt_path) { var path = opt_path || ''; if (!schema) { this.addError(path, 'schemaRequired'); return; } // If this schema defines itself as reference type, save it in this.types. if (schema.id) this.types[schema.id] = schema; // If the schema has an extends property, the instance must validate against // that schema too. if (schema.extends) this.validate(instance, schema.extends, path); // If the schema has a $ref property, the instance must validate against // that schema too. It must be present in this.types to be referenced. var ref = schema.$ref; if (ref) { if (!this.getOrAddType(ref)) this.addError(path, 'unknownSchemaReference', [ref]); else this.validate(instance, this.getOrAddType(ref), path) } // If the schema has a choices property, the instance must validate against at // least one of the items in that array. if (schema.choices) { this.validateChoices(instance, schema, path); return; } // If the schema has an enum property, the instance must be one of those // values. if (schema.enum) { if (!this.validateEnum(instance, schema, path)) return; } if (schema.type && schema.type != 'any') { if (!this.validateType(instance, schema, path)) return; // Type-specific validation. switch (schema.type) { case 'object': this.validateObject(instance, schema, path); break; case 'array': this.validateArray(instance, schema, path); break; case 'string': this.validateString(instance, schema, path); break; case 'number': case 'integer': this.validateNumber(instance, schema, path); break; } } }; /** * Validates an instance against a choices schema. The instance must match at * least one of the provided choices. */ JSONSchemaValidator.prototype.validateChoices = function(instance, schema, path) { var originalErrors = this.errors; for (var i = 0; i < schema.choices.length; ++i) { this.errors = []; this.validate(instance, schema.choices[i], path); if (this.errors.length == 0) { this.errors = originalErrors; return; } } this.errors = originalErrors; this.addError(path, 'invalidChoice'); }; /** * Validates an instance against a schema with an enum type. Populates the * |errors| property, and returns a boolean indicating whether the instance * validates. */ JSONSchemaValidator.prototype.validateEnum = function(instance, schema, path) { for (var i = 0; i < schema.enum.length; ++i) { if (instance === enumToString(schema.enum[i])) return true; } this.addError(path, 'invalidEnum', [$Array.join($Array.map(schema.enum, enumToString), ', ')]); return false; }; /** * Validates an instance against an object schema and populates the errors * property. */ JSONSchemaValidator.prototype.validateObject = function(instance, schema, path) { if (schema.properties) { $Array.forEach($Object.keys(schema.properties), function(prop) { var propPath = path ? path + '.' + prop : prop; if (schema.properties[prop] == undefined) { this.addError(propPath, 'invalidPropertyType'); } else if (instance[prop] !== undefined && instance[prop] !== null) { this.validate(instance[prop], schema.properties[prop], propPath); } else if (!schema.properties[prop].optional) { this.addError(propPath, 'propertyRequired'); } }, this); } // If "instanceof" property is set, check that this object inherits from // the specified constructor (function). if (schema.isInstanceOf) { if (!isInstanceOfClass(instance, schema.isInstanceOf)) this.addError(path || '', 'notInstance', [schema.isInstanceOf]); } // Exit early from additional property check if "type":"any" is defined. if (schema.additionalProperties && schema.additionalProperties.type && schema.additionalProperties.type == 'any') { return; } // By default, additional properties are not allowed on instance objects. This // can be overridden by setting the additionalProperties property to a schema // which any additional properties must validate against. $Array.forEach($Object.keys(instance), function(prop) { if (schema.properties && $Object.hasOwnProperty(schema.properties, prop)) return; var propPath = path ? path + '.' + prop : prop; if (schema.additionalProperties) this.validate(instance[prop], schema.additionalProperties, propPath); else this.addError(propPath, 'unexpectedProperty'); }, this); }; /** * Validates an instance against an array schema and populates the errors * property. */ JSONSchemaValidator.prototype.validateArray = function(instance, schema, path) { var typeOfItems = JSONSchemaValidator.getType(schema.items); if (typeOfItems == 'object') { if (schema.minItems && instance.length < schema.minItems) { this.addError(path, 'arrayMinItems', [schema.minItems]); } if (typeof schema.maxItems != 'undefined' && instance.length > schema.maxItems) { this.addError(path, 'arrayMaxItems', [schema.maxItems]); } // If the items property is a single schema, each item in the array must // have that schema. for (var i = 0; i < instance.length; ++i) { this.validate(instance[i], schema.items, path + '.' + i); } } else if (typeOfItems == 'array') { // If the items property is an array of schemas, each item in the array must // validate against the corresponding schema. for (var i = 0; i < schema.items.length; ++i) { var itemPath = path ? path + '.' + i : $String.self(i); if ($Object.hasOwnProperty(instance, i) && !isOptionalValue(instance[i])) { this.validate(instance[i], schema.items[i], itemPath); } else if (!schema.items[i].optional) { this.addError(itemPath, 'itemRequired'); } } if (schema.additionalProperties) { for (var i = schema.items.length; i < instance.length; ++i) { var itemPath = path ? path + '.' + i : $String.self(i); this.validate(instance[i], schema.additionalProperties, itemPath); } } else if (instance.length > schema.items.length) { this.addError(path, 'arrayMaxItems', [schema.items.length]); } } }; /** * Validates a string and populates the errors property. */ JSONSchemaValidator.prototype.validateString = function(instance, schema, path) { if (schema.minLength && instance.length < schema.minLength) this.addError(path, 'stringMinLength', [schema.minLength]); if (schema.maxLength && instance.length > schema.maxLength) this.addError(path, 'stringMaxLength', [schema.maxLength]); if (schema.pattern && !schema.pattern.test(instance)) this.addError(path, 'stringPattern', [schema.pattern]); }; /** * Validates a number and populates the errors property. The instance is * assumed to be a number. */ JSONSchemaValidator.prototype.validateNumber = function(instance, schema, path) { // Forbid NaN, +Infinity, and -Infinity. Our APIs don't use them, and // JSON serialization encodes them as 'null'. Re-evaluate supporting // them if we add an API that could reasonably take them as a parameter. if (isNaN(instance) || instance == Number.POSITIVE_INFINITY || instance == Number.NEGATIVE_INFINITY ) this.addError(path, 'numberFiniteNotNan', [instance]); if (schema.minimum !== undefined && instance < schema.minimum) this.addError(path, 'numberMinValue', [schema.minimum]); if (schema.maximum !== undefined && instance > schema.maximum) this.addError(path, 'numberMaxValue', [schema.maximum]); // Check for integer values outside of -2^31..2^31-1. if (schema.type === 'integer' && (instance | 0) !== instance) this.addError(path, 'numberIntValue', []); // We don't have a saved copy of Math, and it's not worth it just for a // 10^x function. var getPowerOfTen = function(pow) { // '10' is kind of an arbitrary number of maximum decimal places, but it // ensures we don't do anything crazy, and we should never need to restrict // decimals to a number higher than that. DCHECK(pow >= 1 && pow <= 10); DCHECK(pow % 1 === 0); var multiplier = 10; while (--pow) multiplier *= 10; return multiplier; }; if (schema.maxDecimal && (instance * getPowerOfTen(schema.maxDecimal)) % 1) { this.addError(path, 'numberMaxDecimal', [schema.maxDecimal]); } }; /** * Validates the primitive type of an instance and populates the errors * property. Returns true if the instance validates, false otherwise. */ JSONSchemaValidator.prototype.validateType = function(instance, schema, path) { var actualType = JSONSchemaValidator.getType(instance); if (schema.type == actualType || (schema.type == 'number' && actualType == 'integer')) { return true; } else if (schema.type == 'integer' && actualType == 'number') { this.addError(path, 'invalidTypeIntegerNumber'); return false; } else { this.addError(path, 'invalidType', [schema.type, actualType]); return false; } }; /** * Adds an error message. |key| is an index into the |messages| object. * |replacements| is an array of values to replace '*' characters in the * message. */ JSONSchemaValidator.prototype.addError = function(path, key, replacements) { $Array.push(this.errors, { __proto__: null, path: path, message: JSONSchemaValidator.formatError(key, replacements) }); }; /** * Resets errors to an empty list so you can call 'validate' again. */ JSONSchemaValidator.prototype.resetErrors = function() { this.errors = []; }; exports.$set('JSONSchemaValidator', JSONSchemaValidator); exports.$set('loadTypeSchema', loadTypeSchema); // 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. if ((typeof mojo === 'undefined') || !mojo.bindingsLibraryInitialized) { loadScript('mojo_bindings'); } loadScript('extensions/common/mojo/keep_alive.mojom'); /** * An object that keeps the background page alive until closed. * @constructor * @alias module:keep_alive~KeepAlive */ function KeepAlive() { var pipe = Mojo.createMessagePipe(); /** * The handle to the keep-alive object in the browser. * @type {!MojoHandle} * @private */ this.handle_ = pipe.handle0; Mojo.bindInterface(extensions.KeepAlive.name, pipe.handle1); } /** * Removes this keep-alive. */ KeepAlive.prototype.close = function() { this.handle_.close(); }; /** * Creates a keep-alive. * @return {!module:keep_alive~KeepAlive} A new keep-alive. */ exports.$set('createKeepAlive', function() { return new 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. 'use strict'; (function() { var mojomId = 'extensions/common/mojo/keep_alive.mojom'; if (mojo.internal.isMojomLoaded(mojomId)) { console.warn('The following mojom is loaded multiple times: ' + mojomId); return; } mojo.internal.markMojomLoaded(mojomId); var bindings = mojo; var associatedBindings = mojo; var codec = mojo.internal; var validator = mojo.internal; var exports = mojo.internal.exposeNamespace('extensions'); function KeepAlivePtr(handleOrPtrInfo) { this.ptr = new bindings.InterfacePtrController(KeepAlive, handleOrPtrInfo); } function KeepAliveAssociatedPtr(associatedInterfacePtrInfo) { this.ptr = new associatedBindings.AssociatedInterfacePtrController( KeepAlive, associatedInterfacePtrInfo); } KeepAliveAssociatedPtr.prototype = Object.create(KeepAlivePtr.prototype); KeepAliveAssociatedPtr.prototype.constructor = KeepAliveAssociatedPtr; function KeepAliveProxy(receiver) { this.receiver_ = receiver; } function KeepAliveStub(delegate) { this.delegate_ = delegate; } KeepAliveStub.prototype.accept = function(message) { var reader = new codec.MessageReader(message); switch (reader.messageName) { default: return false; } }; KeepAliveStub.prototype.acceptWithResponder = function(message, responder) { var reader = new codec.MessageReader(message); switch (reader.messageName) { default: return false; } }; function validateKeepAliveRequest(messageValidator) { return validator.validationError.NONE; } function validateKeepAliveResponse(messageValidator) { return validator.validationError.NONE; } var KeepAlive = { name: 'extensions::KeepAlive', kVersion: 0, ptrClass: KeepAlivePtr, proxyClass: KeepAliveProxy, stubClass: KeepAliveStub, validateRequest: validateKeepAliveRequest, validateResponse: null, }; KeepAliveStub.prototype.validator = validateKeepAliveRequest; KeepAliveProxy.prototype.validator = null; exports.KeepAlive = KeepAlive; exports.KeepAlivePtr = KeepAlivePtr; exports.KeepAliveAssociatedPtr = KeepAliveAssociatedPtr; })();// 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 GetAvailability = requireNative('v8_context').GetAvailability; var GetGlobal = requireNative('sendRequest').GetGlobal; // Utility for setting chrome.*.lastError. // // A utility here is useful for two reasons: // 1. For backwards compatibility we need to set chrome.extension.lastError, // but not all contexts actually have access to the extension namespace. // 2. When calling across contexts, the global object that gets lastError set // needs to be that of the caller. We force callers to explicitly specify // the chrome object to try to prevent bugs here. /** * Sets the last error for |name| on |targetChrome| to |message| with an * optional |stack|. */ function set(name, message, stack, targetChrome) { if (!targetChrome) { var errorMessage = name + ': ' + message; if (stack != null && stack != '') errorMessage += '\n' + stack; throw new Error('No chrome object to set error: ' + errorMessage); } clear(targetChrome); // in case somebody has set a sneaky getter/setter var errorObject = { message: message }; if (GetAvailability('extension.lastError').is_available) targetChrome.extension.lastError = errorObject; assertRuntimeIsAvailable(); // We check to see if developers access runtime.lastError in order to decide // whether or not to log it in the (error) console. privates(targetChrome.runtime).accessedLastError = false; $Object.defineProperty(targetChrome.runtime, 'lastError', { configurable: true, get: function() { privates(targetChrome.runtime).accessedLastError = true; return errorObject; }, set: function(error) { errorObject = errorObject; }}); }; /** * Check if anyone has checked chrome.runtime.lastError since it was set. * @param {Object} targetChrome the Chrome object to check. * @return boolean True if the lastError property was set. */ function hasAccessed(targetChrome) { assertRuntimeIsAvailable(); return privates(targetChrome.runtime).accessedLastError === true; } /** * Check whether there is an error set on |targetChrome| without setting * |accessedLastError|. * @param {Object} targetChrome the Chrome object to check. * @return boolean Whether lastError has been set. */ function hasError(targetChrome) { if (!targetChrome) throw new Error('No target chrome to check'); assertRuntimeIsAvailable(); return $Object.hasOwnProperty(targetChrome.runtime, 'lastError'); }; /** * Clears the last error on |targetChrome|. */ function clear(targetChrome) { if (!targetChrome) throw new Error('No target chrome to clear error'); if (GetAvailability('extension.lastError').is_available) delete targetChrome.extension.lastError; assertRuntimeIsAvailable(); delete targetChrome.runtime.lastError; delete privates(targetChrome.runtime).accessedLastError; }; function assertRuntimeIsAvailable() { // chrome.runtime should always be available, but maybe it's disappeared for // some reason? Add debugging for http://crbug.com/258526. var runtimeAvailability = GetAvailability('runtime.lastError'); if (!runtimeAvailability.is_available) { throw new Error('runtime.lastError is not available: ' + runtimeAvailability.message); } if (!chrome.runtime) throw new Error('runtime namespace is null or undefined'); } /** * Runs |callback(args)| with last error args as in set(). * * The target chrome object is the global object's of the callback, so this * method won't work if the real callback has been wrapped (etc). */ function run(name, message, stack, callback, args) { var global = GetGlobal(callback); var targetChrome = global && global.chrome; set(name, message, stack, targetChrome); try { $Function.apply(callback, undefined, args); } finally { reportIfUnchecked(name, targetChrome, stack); clear(targetChrome); } } /** * Checks whether chrome.runtime.lastError has been accessed if set. * If it was set but not accessed, the error is reported to the console. * * @param {string=} name - name of API. * @param {Object} targetChrome - the Chrome object to check. * @param {string=} stack - Stack trace of the call up to the error. */ function reportIfUnchecked(name, targetChrome, stack) { if (hasAccessed(targetChrome) || !hasError(targetChrome)) return; var message = targetChrome.runtime.lastError.message; console.error("Unchecked runtime.lastError while running " + (name || "unknown") + ": " + message + (stack ? "\n" + stack : "")); } exports.$set('clear', clear); exports.$set('hasAccessed', hasAccessed); exports.$set('hasError', hasError); exports.$set('set', set); exports.$set('run', run); exports.$set('reportIfUnchecked', reportIfUnchecked); // 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. // chrome.runtime.messaging API implementation. // TODO(robwu): Fix this indentation. // TODO(kalman): factor requiring chrome out of here. var chrome = requireNative('chrome').GetChrome(); var logActivity = requireNative('activityLogger'); var logging = requireNative('logging'); var messagingNatives = requireNative('messaging_natives'); var processNatives = requireNative('process'); var utils = require('utils'); var messagingUtils = require('messaging_utils'); // The reserved channel name for the sendRequest/send(Native)Message APIs. // Note: sendRequest is deprecated. var kRequestChannel = "chrome.extension.sendRequest"; var kMessageChannel = "chrome.runtime.sendMessage"; var kNativeMessageChannel = "chrome.runtime.sendNativeMessage"; var kPortClosedError = 'Attempting to use a disconnected port object'; var jsEvent; function createAnonymousEvent(schema) { if (bindingUtil) { var supportsFilters = false; var supportsLazyListeners = false; // Native custom events ignore schema. return bindingUtil.createCustomEvent(undefined, undefined, supportsFilters, supportsLazyListeners); } var options = { __proto__: null, unmanaged: true, }; if (!jsEvent) jsEvent = require('event_bindings').Event; return new jsEvent(undefined, schema, options); } function invalidateEvent(event) { if (bindingUtil) bindingUtil.invalidateEvent(event); else privates(event).impl.destroy_(); } var jsLastError = bindingUtil ? undefined : require('lastError'); function setLastError(name, error) { if (bindingUtil) bindingUtil.setLastError(error); else jsLastError.set(name, error, null, chrome); } function clearLastError() { if (bindingUtil) bindingUtil.clearLastError(); else jsLastError.clear(chrome); } function hasLastError() { if (bindingUtil) return bindingUtil.hasLastError(); else return jsLastError.hasError(chrome); } // Map of port IDs to port object. var ports = {__proto__: null}; // Port object. Represents a connection to another script context through // which messages can be passed. function PortImpl(portId, opt_name) { this.portId_ = portId; this.name = opt_name; // Note: Keep these schemas in sync with the documentation in runtime.json var portSchema = { __proto__: null, name: 'port', $ref: 'runtime.Port', }; var messageSchema = { __proto__: null, name: 'message', type: 'any', optional: true, }; this.onDisconnect = createAnonymousEvent([portSchema]); this.onMessage = createAnonymousEvent([messageSchema, portSchema]); } $Object.setPrototypeOf(PortImpl.prototype, null); // Sends a message asynchronously to the context on the other end of this // port. PortImpl.prototype.postMessage = function(msg) { if (!$Object.hasOwnProperty(ports, this.portId_)) throw new Error(kPortClosedError); // JSON.stringify doesn't support a root object which is undefined. if (msg === undefined) msg = null; msg = $JSON.stringify(msg); if (msg === undefined) { // JSON.stringify can fail with unserializable objects. Log an error and // drop the message. // // TODO(kalman/mpcomplete): it would be better to do the same validation // here that we do for runtime.sendMessage (and variants), i.e. throw an // schema validation Error, but just maintain the old behavior until // there's a good reason not to (http://crbug.com/263077). console.error('Illegal argument to Port.postMessage'); return; } var error = messagingNatives.PostMessage(this.portId_, msg); if (error) throw new Error(error); }; // Disconnects the port from the other end. PortImpl.prototype.disconnect = function() { if (!$Object.hasOwnProperty(ports, this.portId_)) return; // disconnect() on an already-closed port is a no-op. messagingNatives.CloseChannel(this.portId_, true); this.destroy_(); }; // Close this specific port without forcing the channel to close. The channel // will close if this was the only port at this end of the channel. PortImpl.prototype.disconnectSoftly = function() { if (!$Object.hasOwnProperty(ports, this.portId_)) return; messagingNatives.CloseChannel(this.portId_, false); this.destroy_(); }; PortImpl.prototype.destroy_ = function() { invalidateEvent(this.onDisconnect); invalidateEvent(this.onMessage); delete ports[this.portId_]; }; // Hidden port creation function. We don't want to expose an API that lets // people add arbitrary port IDs to the port list. function createPort(portId, opt_name) { if (ports[portId]) throw new Error("Port '" + portId + "' already exists."); var port = new Port(portId, opt_name); ports[portId] = port; return port; }; // Helper function for dispatchOnRequest. function handleSendRequestError(isSendMessage, responseCallbackPreserved, sourceExtensionId, targetExtensionId, sourceUrl) { var errorMsg; var eventName = isSendMessage ? 'runtime.onMessage' : 'extension.onRequest'; if (isSendMessage && !responseCallbackPreserved) { errorMsg = 'The chrome.' + eventName + ' listener must return true if you ' + 'want to send a response after the listener returns'; } else { errorMsg = 'Cannot send a response more than once per chrome.' + eventName + ' listener per document'; } errorMsg += ' (message was sent by extension' + sourceExtensionId; if (sourceExtensionId && sourceExtensionId !== targetExtensionId) errorMsg += ' for extension ' + targetExtensionId; if (sourceUrl) errorMsg += ' for URL ' + sourceUrl; errorMsg += ').'; setLastError(eventName, errorMsg); } // Helper function for dispatchOnConnect function dispatchOnRequest(portId, channelName, sender, sourceExtensionId, targetExtensionId, sourceUrl, isExternal) { var isSendMessage = channelName == kMessageChannel; var requestEvent = null; if (isSendMessage) { if (chrome.runtime) { requestEvent = isExternal ? chrome.runtime.onMessageExternal : chrome.runtime.onMessage; } } else { if (chrome.extension) { requestEvent = isExternal ? chrome.extension.onRequestExternal : chrome.extension.onRequest; } } if (!requestEvent) return false; if (!requestEvent.hasListeners()) return false; var port = createPort(portId, channelName); function messageListener(request) { var responseCallbackPreserved = false; var responseCallback = function(response) { if (port) { port.postMessage(response); // TODO(robwu): This can be changed to disconnect() because there is // no point in allowing other receivers at this end of the port to // keep the channel alive because the opener port can only receive one // message. privates(port).impl.disconnectSoftly(); port = null; } else { // We nulled out port when sending the response, and now the page // is trying to send another response for the same request. handleSendRequestError(isSendMessage, responseCallbackPreserved, sourceExtensionId, targetExtensionId); } }; // In case the extension never invokes the responseCallback, and also // doesn't keep a reference to it, we need to clean up the port. Do // so by attaching to the garbage collection of the responseCallback // using some native hackery. // // If the context is destroyed before this has a chance to execute, // BindToGC knows to release |portId| (important for updating C++ state // both in this renderer and on the other end). We don't need to clear // any JavaScript state, as calling destroy_() would usually do - but // the context has been destroyed, so there isn't any JS state to clear. messagingNatives.BindToGC(responseCallback, function() { if (port) { privates(port).impl.disconnectSoftly(); port = null; } }, portId); var rv = requestEvent.dispatch(request, sender, responseCallback); if (isSendMessage) { responseCallbackPreserved = rv && rv.results && $Array.indexOf(rv.results, true) > -1; if (!responseCallbackPreserved && port) { // If they didn't access the response callback, they're not // going to send a response, so clean up the port immediately. privates(port).impl.disconnectSoftly(); port = null; } } } port.onMessage.addListener(messageListener); var eventName = isSendMessage ? "runtime.onMessage" : "extension.onRequest"; if (isExternal) eventName += "External"; logActivity.LogEvent(targetExtensionId, eventName, [sourceExtensionId, sourceUrl]); return true; } // Called by native code when a channel has been opened to this context. function dispatchOnConnect(portId, channelName, sourceTab, sourceFrameId, guestProcessId, guestRenderFrameRoutingId, sourceExtensionId, targetExtensionId, sourceUrl, tlsChannelId) { var wasPortUsed = dispatchOnConnectImpl(portId, channelName, sourceTab, sourceFrameId, guestProcessId, guestRenderFrameRoutingId, sourceExtensionId, targetExtensionId, sourceUrl, tlsChannelId); if (!wasPortUsed) { // Since the JS to dispatch the connect event can (in rare cases) be // executed asynchronously from when we check if there are associated // listeners in the native code, it's possible that the listeners have // since been removed. If that's the case (though unlikely), remove the // port. messagingNatives.CloseChannel(portId, false /* force_close */); } } // Helper function to dispatchOnConnect that returns true if the new port // was used. function dispatchOnConnectImpl(portId, channelName, sourceTab, sourceFrameId, guestProcessId, guestRenderFrameRoutingId, sourceExtensionId, targetExtensionId, sourceUrl, tlsChannelId) { // Only create a new Port if someone is actually listening for a connection. // In addition to being an optimization, this also fixes a bug where if 2 // channels were opened to and from the same process, closing one would // close both. var extensionId = processNatives.GetExtensionId(); // messaging_bindings.cc should ensure that this method only gets called for // the right extension. logging.CHECK(targetExtensionId == extensionId); // Determine whether this is coming from another extension, so we can use // the right event. var isExternal = sourceExtensionId != extensionId; var sender = {}; if (sourceExtensionId != '') sender.id = sourceExtensionId; if (sourceUrl) sender.url = sourceUrl; if (sourceTab) sender.tab = sourceTab; if (sourceFrameId >= 0) sender.frameId = sourceFrameId; if (typeof guestProcessId !== 'undefined' && typeof guestRenderFrameRoutingId !== 'undefined') { // Note that |guestProcessId| and |guestRenderFrameRoutingId| are not // standard fields on MessageSender and should not be exposed to drive-by // extensions; it is only exposed to component extensions. logging.CHECK(processNatives.IsComponentExtension(), "GuestProcessId can only be exposed to component extensions."); sender.guestProcessId = guestProcessId; sender.guestRenderFrameRoutingId = guestRenderFrameRoutingId; } if (typeof tlsChannelId != 'undefined') sender.tlsChannelId = tlsChannelId; // Special case for sendRequest/onRequest and sendMessage/onMessage. if (channelName == kRequestChannel || channelName == kMessageChannel) { return dispatchOnRequest(portId, channelName, sender, sourceExtensionId, targetExtensionId, sourceUrl, isExternal); } var connectEvent = null; if (chrome.runtime) { connectEvent = isExternal ? chrome.runtime.onConnectExternal : chrome.runtime.onConnect; } if (!connectEvent) return false; if (!connectEvent.hasListeners()) return false; var port = createPort(portId, channelName); port.sender = sender; if (processNatives.manifestVersion < 2) port.tab = port.sender.tab; var eventName = (isExternal ? "runtime.onConnectExternal" : "runtime.onConnect"); connectEvent.dispatch(port); logActivity.LogEvent(targetExtensionId, eventName, [sourceExtensionId]); return true; }; // Called by native code when a channel has been closed. function dispatchOnDisconnect(portId, errorMessage) { var port = ports[portId]; if (port) { delete ports[portId]; if (errorMessage) setLastError('Port', errorMessage); try { port.onDisconnect.dispatch(port); } finally { privates(port).impl.destroy_(); clearLastError(); } } }; // Called by native code when a message has been sent to the given port. function dispatchOnMessage(msg, portId) { var port = ports[portId]; if (port) { if (msg) msg = $JSON.parse(msg); port.onMessage.dispatch(msg, port); } }; // Shared implementation used by tabs.sendMessage and runtime.sendMessage. function sendMessageImpl(port, request, responseCallback) { if (port.name != kNativeMessageChannel) port.postMessage(request); if (port.name == kMessageChannel && !responseCallback) { // TODO(mpcomplete): Do this for the old sendRequest API too, after // verifying it doesn't break anything. // Go ahead and disconnect immediately if the sender is not expecting // a response. port.disconnect(); return; } function sendResponseAndClearCallback(response) { // Save a reference so that we don't re-entrantly call responseCallback. var sendResponse = responseCallback; responseCallback = null; if (arguments.length === 0) { // According to the documentation of chrome.runtime.sendMessage, the // callback is invoked without any arguments when an error occurs. sendResponse(); } else { sendResponse(response); } } // Note: make sure to manually remove the onMessage/onDisconnect listeners // that we added before destroying the Port, a workaround to a bug in Port // where any onMessage/onDisconnect listeners added but not removed will // be leaked when the Port is destroyed. // http://crbug.com/320723 tracks a sustainable fix. function disconnectListener() { if (!responseCallback) return; if (hasLastError()) { sendResponseAndClearCallback(); } else { setLastError( port.name, 'The message port closed before a response was received.'); try { sendResponseAndClearCallback(); } finally { clearLastError(); } } } function messageListener(response) { try { if (responseCallback) sendResponseAndClearCallback(response); } finally { port.disconnect(); } } port.onDisconnect.addListener(disconnectListener); port.onMessage.addListener(messageListener); }; function sendMessageUpdateArguments(functionName, hasOptionsArgument) { // skip functionName and hasOptionsArgument var args = $Array.slice(arguments, 2); var alignedArgs = messagingUtils.alignSendMessageArguments(args, hasOptionsArgument); if (!alignedArgs) throw new Error('Invalid arguments to ' + functionName + '.'); return alignedArgs; } function Port() { privates(Port).constructPrivate(this, arguments); } utils.expose(Port, PortImpl, { functions: [ 'disconnect', 'postMessage', ], properties: [ 'name', 'onDisconnect', 'onMessage', ], }); exports.$set('kRequestChannel', kRequestChannel); exports.$set('kMessageChannel', kMessageChannel); exports.$set('kNativeMessageChannel', kNativeMessageChannel); exports.$set('Port', Port); exports.$set('createPort', createPort); exports.$set('sendMessageImpl', sendMessageImpl); exports.$set('sendMessageUpdateArguments', sendMessageUpdateArguments); // For C++ code to call. exports.$set('dispatchOnConnect', dispatchOnConnect); exports.$set('dispatchOnDisconnect', dispatchOnDisconnect); exports.$set('dispatchOnMessage', dispatchOnMessage); // 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. // Routines used to normalize arguments to messaging functions. function alignSendMessageArguments(args, hasOptionsArgument) { // Align missing (optional) function arguments with the arguments that // schema validation is expecting, e.g. // extension.sendRequest(req) -> extension.sendRequest(null, req) // extension.sendRequest(req, cb) -> extension.sendRequest(null, req, cb) if (!args || !args.length) return null; var lastArg = args.length - 1; // responseCallback (last argument) is optional. var responseCallback = null; if (typeof args[lastArg] == 'function') responseCallback = args[lastArg--]; var options = null; if (hasOptionsArgument && lastArg >= 1) { // options (third argument) is optional. It can also be ambiguous which // argument it should match. If there are more than two arguments remaining, // options is definitely present: if (lastArg > 1) { options = args[lastArg--]; } else { // Exactly two arguments remaining. If the first argument is a string, // it should bind to targetId, and the second argument should bind to // request, which is required. In other words, when two arguments remain, // only bind options when the first argument cannot bind to targetId. if (!(args[0] === null || typeof args[0] == 'string')) options = args[lastArg--]; } } // request (second argument) is required. var request = args[lastArg--]; // targetId (first argument, extensionId in the manifest) is optional. var targetId = null; if (lastArg >= 0) targetId = args[lastArg--]; if (lastArg != -1) return null; if (hasOptionsArgument) return [targetId, request, options, responseCallback]; return [targetId, request, responseCallback]; } exports.$set('alignSendMessageArguments', alignSendMessageArguments); // 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. /** * Custom bindings for the mime handler API. */ var binding = apiBridge || require('binding').Binding.create('mimeHandlerPrivate'); var utils = require('utils'); var NO_STREAM_ERROR = 'Streams are only available from a mime handler view guest.'; var STREAM_ABORTED_ERROR = 'Stream has been aborted.'; if ((typeof mojo === 'undefined') || !mojo.bindingsLibraryInitialized) { loadScript('mojo_bindings'); } loadScript('extensions/common/api/mime_handler.mojom'); var servicePtr = new extensions.mimeHandler.MimeHandlerServicePtr; Mojo.bindInterface(extensions.mimeHandler.MimeHandlerService.name, mojo.makeRequest(servicePtr).handle); // Stores a promise to the GetStreamInfo() result to avoid making additional // calls in response to getStreamInfo() calls. var streamInfoPromise; function throwNoStreamError() { throw new Error(NO_STREAM_ERROR); } function createStreamInfoPromise() { return servicePtr.getStreamInfo().then(function(result) { if (!result.streamInfo) throw new Error(STREAM_ABORTED_ERROR); return result.streamInfo; }, throwNoStreamError); } function constructStreamInfoDict(streamInfo) { var headers = {}; for (var header of streamInfo.responseHeaders) { headers[header[0]] = header[1]; } return { mimeType: streamInfo.mimeType, originalUrl: streamInfo.originalUrl, streamUrl: streamInfo.streamUrl, tabId: streamInfo.tabId, embedded: !!streamInfo.embedded, responseHeaders: headers, }; } binding.registerCustomHook(function(bindingsAPI) { var apiFunctions = bindingsAPI.apiFunctions; utils.handleRequestWithPromiseDoNotUse( apiFunctions, 'mimeHandlerPrivate', 'getStreamInfo', function() { if (!streamInfoPromise) streamInfoPromise = createStreamInfoPromise(); return streamInfoPromise.then(constructStreamInfoDict); }); utils.handleRequestWithPromiseDoNotUse( apiFunctions, 'mimeHandlerPrivate', 'abortStream', function() { return servicePtr.abortStream().then(function() {}); }); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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() { var mojomId = 'extensions/common/api/mime_handler.mojom'; if (mojo.internal.isMojomLoaded(mojomId)) { console.warn('The following mojom is loaded multiple times: ' + mojomId); return; } mojo.internal.markMojomLoaded(mojomId); var bindings = mojo; var associatedBindings = mojo; var codec = mojo.internal; var validator = mojo.internal; var exports = mojo.internal.exposeNamespace('extensions.mimeHandler'); function StreamInfo(values) { this.initDefaults_(); this.initFields_(values); } StreamInfo.prototype.initDefaults_ = function() { this.mimeType = null; this.originalUrl = null; this.streamUrl = null; this.tabId = 0; this.embedded = false; this.responseHeaders = null; }; StreamInfo.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; StreamInfo.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 48} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; // validate StreamInfo.mimeType err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 0, false) if (err !== validator.validationError.NONE) return err; // validate StreamInfo.originalUrl err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 8, false) if (err !== validator.validationError.NONE) return err; // validate StreamInfo.streamUrl err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 16, false) if (err !== validator.validationError.NONE) return err; // validate StreamInfo.responseHeaders err = messageValidator.validateMapPointer(offset + codec.kStructHeaderSize + 32, false, codec.String, codec.String, false); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; StreamInfo.encodedSize = codec.kStructHeaderSize + 40; StreamInfo.decode = function(decoder) { var packed; var val = new StreamInfo(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.mimeType = decoder.decodeStruct(codec.String); val.originalUrl = decoder.decodeStruct(codec.String); val.streamUrl = decoder.decodeStruct(codec.String); val.tabId = decoder.decodeStruct(codec.Int32); packed = decoder.readUint8(); val.embedded = (packed >> 0) & 1 ? true : false; decoder.skip(1); decoder.skip(1); decoder.skip(1); val.responseHeaders = decoder.decodeMapPointer(codec.String, codec.String); return val; }; StreamInfo.encode = function(encoder, val) { var packed; encoder.writeUint32(StreamInfo.encodedSize); encoder.writeUint32(0); encoder.encodeStruct(codec.String, val.mimeType); encoder.encodeStruct(codec.String, val.originalUrl); encoder.encodeStruct(codec.String, val.streamUrl); encoder.encodeStruct(codec.Int32, val.tabId); packed = 0; packed |= (val.embedded & 1) << 0 encoder.writeUint8(packed); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.encodeMapPointer(codec.String, codec.String, val.responseHeaders); }; function MimeHandlerService_GetStreamInfo_Params(values) { this.initDefaults_(); this.initFields_(values); } MimeHandlerService_GetStreamInfo_Params.prototype.initDefaults_ = function() { }; MimeHandlerService_GetStreamInfo_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; MimeHandlerService_GetStreamInfo_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; MimeHandlerService_GetStreamInfo_Params.encodedSize = codec.kStructHeaderSize + 0; MimeHandlerService_GetStreamInfo_Params.decode = function(decoder) { var packed; var val = new MimeHandlerService_GetStreamInfo_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; MimeHandlerService_GetStreamInfo_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(MimeHandlerService_GetStreamInfo_Params.encodedSize); encoder.writeUint32(0); }; function MimeHandlerService_GetStreamInfo_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } MimeHandlerService_GetStreamInfo_ResponseParams.prototype.initDefaults_ = function() { this.streamInfo = null; }; MimeHandlerService_GetStreamInfo_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; MimeHandlerService_GetStreamInfo_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 16} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; // validate MimeHandlerService_GetStreamInfo_ResponseParams.streamInfo err = messageValidator.validateStructPointer(offset + codec.kStructHeaderSize + 0, StreamInfo, true); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; MimeHandlerService_GetStreamInfo_ResponseParams.encodedSize = codec.kStructHeaderSize + 8; MimeHandlerService_GetStreamInfo_ResponseParams.decode = function(decoder) { var packed; var val = new MimeHandlerService_GetStreamInfo_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.streamInfo = decoder.decodeStructPointer(StreamInfo); return val; }; MimeHandlerService_GetStreamInfo_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(MimeHandlerService_GetStreamInfo_ResponseParams.encodedSize); encoder.writeUint32(0); encoder.encodeStructPointer(StreamInfo, val.streamInfo); }; function MimeHandlerService_AbortStream_Params(values) { this.initDefaults_(); this.initFields_(values); } MimeHandlerService_AbortStream_Params.prototype.initDefaults_ = function() { }; MimeHandlerService_AbortStream_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; MimeHandlerService_AbortStream_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; MimeHandlerService_AbortStream_Params.encodedSize = codec.kStructHeaderSize + 0; MimeHandlerService_AbortStream_Params.decode = function(decoder) { var packed; var val = new MimeHandlerService_AbortStream_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; MimeHandlerService_AbortStream_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(MimeHandlerService_AbortStream_Params.encodedSize); encoder.writeUint32(0); }; function MimeHandlerService_AbortStream_ResponseParams(values) { this.initDefaults_(); this.initFields_(values); } MimeHandlerService_AbortStream_ResponseParams.prototype.initDefaults_ = function() { }; MimeHandlerService_AbortStream_ResponseParams.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; MimeHandlerService_AbortStream_ResponseParams.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 8} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; MimeHandlerService_AbortStream_ResponseParams.encodedSize = codec.kStructHeaderSize + 0; MimeHandlerService_AbortStream_ResponseParams.decode = function(decoder) { var packed; var val = new MimeHandlerService_AbortStream_ResponseParams(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); return val; }; MimeHandlerService_AbortStream_ResponseParams.encode = function(encoder, val) { var packed; encoder.writeUint32(MimeHandlerService_AbortStream_ResponseParams.encodedSize); encoder.writeUint32(0); }; var kMimeHandlerService_GetStreamInfo_Name = 1466718350; var kMimeHandlerService_AbortStream_Name = 253828292; function MimeHandlerServicePtr(handleOrPtrInfo) { this.ptr = new bindings.InterfacePtrController(MimeHandlerService, handleOrPtrInfo); } function MimeHandlerServiceAssociatedPtr(associatedInterfacePtrInfo) { this.ptr = new associatedBindings.AssociatedInterfacePtrController( MimeHandlerService, associatedInterfacePtrInfo); } MimeHandlerServiceAssociatedPtr.prototype = Object.create(MimeHandlerServicePtr.prototype); MimeHandlerServiceAssociatedPtr.prototype.constructor = MimeHandlerServiceAssociatedPtr; function MimeHandlerServiceProxy(receiver) { this.receiver_ = receiver; } MimeHandlerServicePtr.prototype.getStreamInfo = function() { return MimeHandlerServiceProxy.prototype.getStreamInfo .apply(this.ptr.getProxy(), arguments); }; MimeHandlerServiceProxy.prototype.getStreamInfo = function() { var params = new MimeHandlerService_GetStreamInfo_Params(); return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kMimeHandlerService_GetStreamInfo_Name, codec.align(MimeHandlerService_GetStreamInfo_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(MimeHandlerService_GetStreamInfo_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(MimeHandlerService_GetStreamInfo_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; MimeHandlerServicePtr.prototype.abortStream = function() { return MimeHandlerServiceProxy.prototype.abortStream .apply(this.ptr.getProxy(), arguments); }; MimeHandlerServiceProxy.prototype.abortStream = function() { var params = new MimeHandlerService_AbortStream_Params(); return new Promise(function(resolve, reject) { var builder = new codec.MessageV1Builder( kMimeHandlerService_AbortStream_Name, codec.align(MimeHandlerService_AbortStream_Params.encodedSize), codec.kMessageExpectsResponse, 0); builder.encodeStruct(MimeHandlerService_AbortStream_Params, params); var message = builder.finish(); this.receiver_.acceptAndExpectResponse(message).then(function(message) { var reader = new codec.MessageReader(message); var responseParams = reader.decodeStruct(MimeHandlerService_AbortStream_ResponseParams); resolve(responseParams); }).catch(function(result) { reject(Error("Connection error: " + result)); }); }.bind(this)); }; function MimeHandlerServiceStub(delegate) { this.delegate_ = delegate; } MimeHandlerServiceStub.prototype.getStreamInfo = function() { return this.delegate_ && this.delegate_.getStreamInfo && this.delegate_.getStreamInfo(); } MimeHandlerServiceStub.prototype.abortStream = function() { return this.delegate_ && this.delegate_.abortStream && this.delegate_.abortStream(); } MimeHandlerServiceStub.prototype.accept = function(message) { var reader = new codec.MessageReader(message); switch (reader.messageName) { default: return false; } }; MimeHandlerServiceStub.prototype.acceptWithResponder = function(message, responder) { var reader = new codec.MessageReader(message); switch (reader.messageName) { case kMimeHandlerService_GetStreamInfo_Name: var params = reader.decodeStruct(MimeHandlerService_GetStreamInfo_Params); this.getStreamInfo().then(function(response) { var responseParams = new MimeHandlerService_GetStreamInfo_ResponseParams(); responseParams.streamInfo = response.streamInfo; var builder = new codec.MessageV1Builder( kMimeHandlerService_GetStreamInfo_Name, codec.align(MimeHandlerService_GetStreamInfo_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(MimeHandlerService_GetStreamInfo_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; case kMimeHandlerService_AbortStream_Name: var params = reader.decodeStruct(MimeHandlerService_AbortStream_Params); this.abortStream().then(function(response) { var responseParams = new MimeHandlerService_AbortStream_ResponseParams(); var builder = new codec.MessageV1Builder( kMimeHandlerService_AbortStream_Name, codec.align(MimeHandlerService_AbortStream_ResponseParams.encodedSize), codec.kMessageIsResponse, reader.requestID); builder.encodeStruct(MimeHandlerService_AbortStream_ResponseParams, responseParams); var message = builder.finish(); responder.accept(message); }); return true; default: return false; } }; function validateMimeHandlerServiceRequest(messageValidator) { var message = messageValidator.message; var paramsClass = null; switch (message.getName()) { case kMimeHandlerService_GetStreamInfo_Name: if (message.expectsResponse()) paramsClass = MimeHandlerService_GetStreamInfo_Params; break; case kMimeHandlerService_AbortStream_Name: if (message.expectsResponse()) paramsClass = MimeHandlerService_AbortStream_Params; break; } if (paramsClass === null) return validator.validationError.NONE; return paramsClass.validate(messageValidator, messageValidator.message.getHeaderNumBytes()); } function validateMimeHandlerServiceResponse(messageValidator) { var message = messageValidator.message; var paramsClass = null; switch (message.getName()) { case kMimeHandlerService_GetStreamInfo_Name: if (message.isResponse()) paramsClass = MimeHandlerService_GetStreamInfo_ResponseParams; break; case kMimeHandlerService_AbortStream_Name: if (message.isResponse()) paramsClass = MimeHandlerService_AbortStream_ResponseParams; break; } if (paramsClass === null) return validator.validationError.NONE; return paramsClass.validate(messageValidator, messageValidator.message.getHeaderNumBytes()); } var MimeHandlerService = { name: 'extensions::mime_handler::MimeHandlerService', kVersion: 0, ptrClass: MimeHandlerServicePtr, proxyClass: MimeHandlerServiceProxy, stubClass: MimeHandlerServiceStub, validateRequest: validateMimeHandlerServiceRequest, validateResponse: validateMimeHandlerServiceResponse, }; MimeHandlerServiceStub.prototype.validator = validateMimeHandlerServiceRequest; MimeHandlerServiceProxy.prototype.validator = validateMimeHandlerServiceResponse; exports.StreamInfo = StreamInfo; exports.MimeHandlerService = MimeHandlerService; exports.MimeHandlerServicePtr = MimeHandlerServicePtr; exports.MimeHandlerServiceAssociatedPtr = MimeHandlerServiceAssociatedPtr; })();// 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. // Routines used to validate and normalize arguments. // TODO(benwells): unit test this file. var JSONSchemaValidator = require('json_schema').JSONSchemaValidator; var schemaValidator = new JSONSchemaValidator(); // Validate arguments. function validate(args, parameterSchemas) { if (args.length > parameterSchemas.length) throw new Error('Too many arguments.'); for (var i = 0; i < parameterSchemas.length; ++i) { if ($Object.hasOwnProperty(args, i) && args[i] !== null && args[i] !== undefined) { schemaValidator.resetErrors(); schemaValidator.validate(args[i], parameterSchemas[i]); if (schemaValidator.errors.length == 0) continue; var message = 'Invalid value for argument ' + (i + 1) + '. '; $Array.forEach(schemaValidator.errors, function(err) { if (err.path) { message += "Property '" + err.path + "': "; } message += err.message; message = message.substring(0, message.length - 1); message += ', '; }); message = message.substring(0, message.length - 2); message += '.'; throw new Error(message); } else if (!parameterSchemas[i].optional) { throw new Error('Parameter ' + (i + 1) + ' (' + parameterSchemas[i].name + ') is required.'); } } } // Generate all possible signatures for a given API function. function getSignatures(parameterSchemas) { if (parameterSchemas.length === 0) return [[]]; var signatures = []; $Object.setPrototypeOf(signatures, null); $Object.setPrototypeOf(parameterSchemas, null); var remaining = getSignatures($Array.slice(parameterSchemas, 1)); $Object.setPrototypeOf(remaining, null); for (var i = 0; i < remaining.length; ++i) $Array.push(signatures, $Array.concat([parameterSchemas[0]], remaining[i])) if (parameterSchemas[0].optional) return $Array.concat(signatures, remaining); return signatures; }; // Return true if arguments match a given signature's schema. function argumentsMatchSignature(args, candidateSignature) { if (args.length != candidateSignature.length) return false; for (var i = 0; i < candidateSignature.length; ++i) { var argType = JSONSchemaValidator.getType(args[i]); if (!schemaValidator.isValidSchemaType(argType, candidateSignature[i])) return false; } return true; }; // Finds the function signature for the given arguments. function resolveSignature(args, definedSignature) { var candidateSignatures = getSignatures(definedSignature); for (var i = 0; i < candidateSignatures.length; ++i) { if (argumentsMatchSignature(args, candidateSignatures[i])) return candidateSignatures[i]; } return null; }; // Returns a string representing the defined signature of the API function. // Example return value for chrome.windows.getCurrent: // "windows.getCurrent(optional object populate, function callback)" function getParameterSignatureString(name, definedSignature) { var getSchemaTypeString = function(schema) { var schemaTypes = schemaValidator.getAllTypesForSchema(schema); var typeName = $Array.join(schemaTypes, ' or ') + ' ' + schema.name; if (schema.optional) return 'optional ' + typeName; return typeName; }; var typeNames = $Array.map(definedSignature, getSchemaTypeString); return name + '(' + $Array.join(typeNames, ', ') + ')'; }; // Returns a string representing a call to an API function. // Example return value for call: chrome.windows.get(1, callback) is: // "windows.get(int, function)" function getArgumentSignatureString(name, args) { var typeNames = $Array.map(args, JSONSchemaValidator.getType); return name + '(' + $Array.join(typeNames, ', ') + ')'; }; // Finds the correct signature for the given arguments, then validates the // arguments against that signature. Returns a 'normalized' arguments list // where nulls are inserted where optional parameters were omitted. // |args| is expected to be an array. function normalizeArgumentsAndValidate(args, funDef) { if (funDef.allowAmbiguousOptionalArguments) { validate(args, funDef.definition.parameters); return args; } var definedSignature = funDef.definition.parameters; var resolvedSignature = resolveSignature(args, definedSignature); if (!resolvedSignature) throw new Error('Invocation of form ' + getArgumentSignatureString(funDef.name, args) + " doesn't match definition " + getParameterSignatureString(funDef.name, definedSignature)); validate(args, resolvedSignature); var normalizedArgs = []; $Object.setPrototypeOf(normalizedArgs, null); var ai = 0; for (var si = 0; si < definedSignature.length; ++si) { if (definedSignature[si] === resolvedSignature[ai]) $Array.push(normalizedArgs, args[ai++]); else $Array.push(normalizedArgs, null); } return normalizedArgs; }; // Validates that a given schema for an API function is not ambiguous. function isFunctionSignatureAmbiguous(functionDef) { if (functionDef.allowAmbiguousOptionalArguments) return false; var signaturesAmbiguous = function(signature1, signature2) { if (signature1.length != signature2.length) return false; for (var i = 0; i < signature1.length; i++) { if (!schemaValidator.checkSchemaOverlap( signature1[i], signature2[i])) return false; } return true; }; var candidateSignatures = getSignatures(functionDef.parameters); for (var i = 0; i < candidateSignatures.length; ++i) { for (var j = i + 1; j < candidateSignatures.length; ++j) { if (signaturesAmbiguous(candidateSignatures[i], candidateSignatures[j])) return true; } } return false; }; exports.$set('isFunctionSignatureAmbiguous', isFunctionSignatureAmbiguous); exports.$set('normalizeArgumentsAndValidate', normalizeArgumentsAndValidate); exports.$set('schemaValidator', schemaValidator); exports.$set('validate', validate); // 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 exceptionHandler = require('uncaught_exception_handler'); var lastError = require('lastError'); var logging = requireNative('logging'); var natives = requireNative('sendRequest'); var validate = require('schemaUtils').validate; var safeCallbackApply = exceptionHandler.safeCallbackApply; // All outstanding requests from sendRequest(). var requests = { __proto__: null }; // Used to prevent double Activity Logging for API calls that use both custom // bindings and ExtensionFunctions (via sendRequest). var calledSendRequest = false; // Callback handling. function handleResponse(requestId, name, success, responseList, error) { // The chrome objects we will set lastError on. Really we should only be // setting this on the callback's chrome object, but set on ours too since // it's conceivable that something relies on that. var callerChrome = chrome; try { var request = requests[requestId]; logging.DCHECK(request != null); // lastError needs to be set on the caller's chrome object no matter what, // though chances are it's the same as ours (it will be different when // calling API methods on other contexts). if (request.callback) { var global = natives.GetGlobal(request.callback); callerChrome = global ? global.chrome : callerChrome; } lastError.clear(chrome); if (callerChrome !== chrome) lastError.clear(callerChrome); if (!success) { if (!error) error = "Unknown error."; lastError.set(name, error, request.stack, chrome); if (callerChrome !== chrome) lastError.set(name, error, request.stack, callerChrome); } if (request.customCallback) { safeCallbackApply(name, request, request.customCallback, $Array.concat([name, request, request.callback], responseList)); } else if (request.callback) { // Validate callback in debug only -- and only when the // caller has provided a callback. Implementations of api // calls may not return data if they observe the caller // has not provided a callback. if (logging.DCHECK_IS_ON() && !error) { if (!request.callbackSchema.parameters) throw new Error(name + ": no callback schema defined"); validate(responseList, request.callbackSchema.parameters); } safeCallbackApply(name, request, request.callback, responseList); } if (error && !lastError.hasAccessed(chrome)) { // The native call caused an error, but the developer might not have // checked runtime.lastError. lastError.reportIfUnchecked(name, callerChrome, request.stack); } } finally { delete requests[requestId]; lastError.clear(chrome); if (callerChrome !== chrome) lastError.clear(callerChrome); } } function prepareRequest(args, argSchemas) { var request = { __proto__: null }; var argCount = args.length; // Look for callback param. if (argSchemas.length > 0 && argSchemas[argSchemas.length - 1].type == "function") { request.callback = args[args.length - 1]; request.callbackSchema = argSchemas[argSchemas.length - 1]; --argCount; } request.args = $Array.slice(args, 0, argCount); return request; } // Send an API request and optionally register a callback. // |optArgs| is an object with optional parameters as follows: // - customCallback: a callback that should be called instead of the standard // callback. // - forIOThread: true if this function should be handled on the browser IO // thread. // - preserveNullInObjects: true if it is safe for null to be in objects. // - stack: An optional string that contains the stack trace, to be displayed // to the user if an error occurs. function sendRequest(functionName, args, argSchemas, optArgs) { calledSendRequest = true; if (!optArgs) optArgs = { __proto__: null }; logging.DCHECK(optArgs.__proto__ == null); var request = prepareRequest(args, argSchemas); request.stack = optArgs.stack || exceptionHandler.getExtensionStackTrace(); if (optArgs.customCallback) { request.customCallback = optArgs.customCallback; } var hasCallback = request.callback || optArgs.customCallback; var requestId = natives.StartRequest(functionName, request.args, hasCallback, optArgs.forIOThread, optArgs.preserveNullInObjects); delete request.args; request.id = requestId; requests[requestId] = request; } function getCalledSendRequest() { return calledSendRequest; } function clearCalledSendRequest() { calledSendRequest = false; } exports.$set('sendRequest', sendRequest); exports.$set('getCalledSendRequest', getCalledSendRequest); exports.$set('clearCalledSendRequest', clearCalledSendRequest); // Called by C++. exports.$set('handleResponse', handleResponse); // 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 SetIconCommon = requireNative('setIcon').SetIconCommon; function loadImagePath(path, callback) { var img = new Image(); img.onerror = function() { console.error('Could not load action icon \'' + path + '\'.'); }; img.onload = function() { var canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; var canvas_context = canvas.getContext('2d'); canvas_context.clearRect(0, 0, canvas.width, canvas.height); canvas_context.drawImage(img, 0, 0, canvas.width, canvas.height); var imageData = canvas_context.getImageData(0, 0, canvas.width, canvas.height); callback(imageData); }; img.src = path; } function smellsLikeImageData(imageData) { // See if this object at least looks like an ImageData element. // Unfortunately, we cannot use instanceof because the ImageData // constructor is not public. // // We do this manually instead of using JSONSchema to avoid having these // properties show up in the doc. return (typeof imageData == 'object') && ('width' in imageData) && ('height' in imageData) && ('data' in imageData); } function verifyImageData(imageData) { if (!smellsLikeImageData(imageData)) { throw new Error( 'The imageData property must contain an ImageData object or' + ' dictionary of ImageData objects.'); } } /** * Normalizes |details| to a format suitable for sending to the browser, * for example converting ImageData to a binary representation. * * @param {ImageDetails} details * The ImageDetails passed into an extension action-style API. * @param {Function} callback * The callback function to pass processed imageData back to. Note that this * callback may be called reentrantly. */ function setIcon(details, callback) { // Note that iconIndex is actually deprecated, and only available to the // pageAction API. // TODO(kalman): Investigate whether this is for the pageActions API, and if // so, delete it. if ('iconIndex' in details) { callback(details); return; } if ('imageData' in details) { if (smellsLikeImageData(details.imageData)) { var imageData = details.imageData; details.imageData = {}; details.imageData[imageData.width.toString()] = imageData; } else if (typeof details.imageData == 'object' && Object.getOwnPropertyNames(details.imageData).length !== 0) { for (var sizeKey in details.imageData) { verifyImageData(details.imageData[sizeKey]); } } else { verifyImageData(false); } callback(SetIconCommon(details)); return; } if ('path' in details) { if (typeof details.path == 'object') { details.imageData = {}; var detailKeyCount = 0; for (var iconSize in details.path) { ++detailKeyCount; loadImagePath(details.path[iconSize], function(size, imageData) { details.imageData[size] = imageData; if (--detailKeyCount == 0) callback(SetIconCommon(details)); }.bind(null, iconSize)); } if (detailKeyCount == 0) throw new Error('The path property must not be empty.'); } else if (typeof details.path == 'string') { details.imageData = {}; loadImagePath(details.path, function(imageData) { details.imageData[imageData.width.toString()] = imageData; delete details.path; callback(SetIconCommon(details)); }); } return; } throw new Error('Either the path or imageData property must be specified.'); } exports.$set('setIcon', setIcon); // 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. // test_custom_bindings.js // mini-framework for ExtensionApiTest browser tests var binding = apiBridge || require('binding').Binding.create('test'); var environmentSpecificBindings = require('test_environment_specific_bindings'); var GetExtensionAPIDefinitionsForTest = requireNative('apiDefinitions').GetExtensionAPIDefinitionsForTest; var GetAPIFeatures = requireNative('test_features').GetAPIFeatures; var natives = requireNative('test_native_handler'); var userGestures = requireNative('user_gestures'); var GetModuleSystem = requireNative('v8_context').GetModuleSystem; var jsExceptionHandler = bindingUtil ? undefined : require('uncaught_exception_handler'); function setExceptionHandler(handler) { if (bindingUtil) bindingUtil.setExceptionHandler(handler); else jsExceptionHandler.setHandler(handler); } function handleException(message, error) { if (bindingUtil) bindingUtil.handleException(message, error); else jsExceptionHandler.handle(message, error); } binding.registerCustomHook(function(api) { var chromeTest = api.compiledApi; var apiFunctions = api.apiFunctions; chromeTest.tests = chromeTest.tests || []; var currentTest = null; var lastTest = null; var testsFailed = 0; var testCount = 1; var failureException = 'chrome.test.failure'; // Helper function to get around the fact that function names in javascript // are read-only, and you can't assign one to anonymous functions. function testName(test) { return test ? (test.name || test.generatedName) : "(no test)"; } function testDone() { environmentSpecificBindings.testDone(chromeTest.runNextTest); } function allTestsDone() { if (testsFailed == 0) { chromeTest.notifyPass(); } else { chromeTest.notifyFail('Failed ' + testsFailed + ' of ' + testCount + ' tests'); } } var pendingCallbacks = 0; apiFunctions.setHandleRequest('callbackAdded', function() { pendingCallbacks++; var called = null; return function() { if (called != null) { var redundantPrefix = 'Error\n'; chromeTest.fail( 'Callback has already been run. ' + 'First call:\n' + $String.slice(called, redundantPrefix.length) + '\n' + 'Second call:\n' + $String.slice(new Error().stack, redundantPrefix.length)); } called = new Error().stack; pendingCallbacks--; if (pendingCallbacks == 0) { chromeTest.succeed(); } }; }); apiFunctions.setHandleRequest('runNextTest', function() { // There may have been callbacks which were interrupted by failure // exceptions. pendingCallbacks = 0; lastTest = currentTest; currentTest = chromeTest.tests.shift(); if (!currentTest) { allTestsDone(); return; } try { chromeTest.log("( RUN ) " + testName(currentTest)); setExceptionHandler(function(message, e) { if (e !== failureException) chromeTest.fail('uncaught exception: ' + message); }); currentTest.call(); } catch (e) { handleException(e.message, e); } }); apiFunctions.setHandleRequest('fail', function failHandler(message) { chromeTest.log("( FAILED ) " + testName(currentTest)); var stack = {}; // NOTE(devlin): captureStackTrace() populates a stack property of the // passed-in object with the stack trace. The second parameter (failHandler) // represents a function to serve as a relative point, and is removed from // the trace (so that everything doesn't include failHandler in the trace // itself). This (and other APIs) are documented here: // https://github.com/v8/v8/wiki/Stack%20Trace%20API. If we wanted to be // really fancy, there may be more sophisticated ways of doing this. Error.captureStackTrace(stack, failHandler); if (!message) message = "FAIL (no message)"; message += "\n" + stack.stack; console.log("[FAIL] " + testName(currentTest) + ": " + message); testsFailed++; testDone(); // Interrupt the rest of the test. throw failureException; }); apiFunctions.setHandleRequest('succeed', function() { console.log("[SUCCESS] " + testName(currentTest)); chromeTest.log("( SUCCESS )"); testDone(); }); apiFunctions.setHandleRequest('getModuleSystem', function(context) { return GetModuleSystem(context); }); apiFunctions.setHandleRequest('assertTrue', function(test, message) { chromeTest.assertBool(test, true, message); }); apiFunctions.setHandleRequest('assertFalse', function(test, message) { chromeTest.assertBool(test, false, message); }); apiFunctions.setHandleRequest('assertBool', function(test, expected, message) { if (test !== expected) { if (typeof(test) == "string") { if (message) message = test + "\n" + message; else message = test; } chromeTest.fail(message); } }); apiFunctions.setHandleRequest('checkDeepEq', function(expected, actual) { if ((expected === null) != (actual === null)) return false; if (expected === actual) return true; if (typeof(expected) !== typeof(actual)) return false; for (var p in actual) { if ($Object.hasOwnProperty(actual, p) && !$Object.hasOwnProperty(expected, p)) { return false; } } for (var p in expected) { if ($Object.hasOwnProperty(expected, p) && !$Object.hasOwnProperty(actual, p)) { return false; } } for (var p in expected) { var eq = true; switch (typeof(expected[p])) { case 'object': eq = chromeTest.checkDeepEq(expected[p], actual[p]); break; case 'function': eq = (typeof(actual[p]) != 'undefined' && expected[p].toString() == actual[p].toString()); break; default: eq = (expected[p] == actual[p] && typeof(expected[p]) == typeof(actual[p])); break; } if (!eq) return false; } return true; }); apiFunctions.setHandleRequest('assertEq', function(expected, actual, message) { var error_msg = "API Test Error in " + testName(currentTest); if (message) error_msg += ": " + message; if (typeof(expected) == 'object') { if (!chromeTest.checkDeepEq(expected, actual)) { error_msg += "\nActual: " + $JSON.stringify(actual) + "\nExpected: " + $JSON.stringify(expected); chromeTest.fail(error_msg); } return; } if (expected != actual) { chromeTest.fail(error_msg + "\nActual: " + actual + "\nExpected: " + expected); } if (typeof(expected) != typeof(actual)) { chromeTest.fail(error_msg + " (type mismatch)\nActual Type: " + typeof(actual) + "\nExpected Type:" + typeof(expected)); } }); apiFunctions.setHandleRequest('assertNoLastError', function() { if (chrome.runtime.lastError != undefined) { chromeTest.fail("lastError.message == " + chrome.runtime.lastError.message); } }); apiFunctions.setHandleRequest('assertLastError', function(expectedError) { chromeTest.assertEq(typeof(expectedError), 'string'); chromeTest.assertTrue(chrome.runtime.lastError != undefined, "No lastError, but expected " + expectedError); chromeTest.assertEq(expectedError, chrome.runtime.lastError.message); }); apiFunctions.setHandleRequest('assertThrows', function(fn, self, args, message) { chromeTest.assertTrue(typeof fn == 'function'); try { fn.apply(self, args); chromeTest.fail('Did not throw error: ' + fn); } catch (e) { if (e != failureException && message !== undefined) { if (message instanceof RegExp) { chromeTest.assertTrue(message.test(e.message), e.message + ' should match ' + message) } else { chromeTest.assertEq(message, e.message); } } } }); function safeFunctionApply(func, args) { try { if (func) return $Function.apply(func, undefined, args); } catch (e) { if (e === failureException) throw e; handleException(e.message, e); } }; // Wrapper for generating test functions, that takes care of calling // assertNoLastError() and (optionally) succeed() for you. apiFunctions.setHandleRequest('callback', function(func, expectedError) { if (func) { chromeTest.assertEq(typeof(func), 'function'); } var callbackCompleted = chromeTest.callbackAdded(); return function() { if (expectedError == null) { chromeTest.assertNoLastError(); } else { chromeTest.assertLastError(expectedError); } var result; if (func) { result = safeFunctionApply(func, arguments); } callbackCompleted(); return result; }; }); apiFunctions.setHandleRequest('listenOnce', function(event, func) { var callbackCompleted = chromeTest.callbackAdded(); var listener = function() { event.removeListener(listener); safeFunctionApply(func, arguments); callbackCompleted(); }; event.addListener(listener); }); apiFunctions.setHandleRequest('listenForever', function(event, func) { var callbackCompleted = chromeTest.callbackAdded(); var listener = function() { safeFunctionApply(func, arguments); }; var done = function() { event.removeListener(listener); callbackCompleted(); }; event.addListener(listener); return done; }); apiFunctions.setHandleRequest('callbackPass', function(func) { return chromeTest.callback(func); }); apiFunctions.setHandleRequest('callbackFail', function(expectedError, func) { return chromeTest.callback(func, expectedError); }); apiFunctions.setHandleRequest('runTests', function(tests) { chromeTest.tests = tests; testCount = chromeTest.tests.length; chromeTest.runNextTest(); }); apiFunctions.setHandleRequest('getApiDefinitions', function() { return GetExtensionAPIDefinitionsForTest(); }); apiFunctions.setHandleRequest('getApiFeatures', function() { return GetAPIFeatures(); }); apiFunctions.setHandleRequest('isProcessingUserGesture', function() { return userGestures.IsProcessingUserGesture(); }); apiFunctions.setHandleRequest('runWithUserGesture', function(callback) { chromeTest.assertEq(typeof(callback), 'function'); return userGestures.RunWithUserGesture(callback); }); apiFunctions.setHandleRequest('runWithoutUserGesture', function(callback) { chromeTest.assertEq(typeof(callback), 'function'); return userGestures.RunWithoutUserGesture(callback); }); apiFunctions.setHandleRequest('setExceptionHandler', function(callback) { chromeTest.assertEq(typeof(callback), 'function'); setExceptionHandler(callback); }); apiFunctions.setHandleRequest('getWakeEventPage', function() { return natives.GetWakeEventPage(); }); environmentSpecificBindings.registerHooks(api); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // Handles uncaught exceptions thrown by extensions. By default this is to // log an error message, but tests may override this behaviour. var handler = function(message, e) { console.error(message); }; /** * Formats the error message and invokes the error handler. * * @param {string} message - Error message prefix. * @param {Error|*} e - Thrown object. * @param {string=} priorStackTrace - Error message suffix. * @see formatErrorMessage */ function handle(message, e, priorStackTrace) { message = formatErrorMessage(message, e, priorStackTrace); handler(message, e); } // Runs a user-supplied callback safely. function safeCallbackApply(name, request, callback, args) { try { $Function.apply(callback, request, args); } catch (e) { handle('Error in response to ' + name, e, request.stack); } } /** * Append the error description and stack trace to |message|. * * @param {string} message - The prefix of the error message. * @param {Error|*} e - The thrown error object. This object is potentially * unsafe, because it could be generated by an extension. * @param {string=} priorStackTrace - The stack trace to be appended to the * error message. This stack trace must not include stack frames of |e.stack|, * because both stack traces are concatenated. Overlapping stack traces will * confuse extension developers. * @return {string} The formatted error message. */ function formatErrorMessage(message, e, priorStackTrace) { if (e) message += ': ' + safeErrorToString(e, false); var stack; try { // If the stack was set, use it. // |e.stack| could be void in the following common example: // throw "Error message"; stack = $String.self(e && e.stack); } catch (e) {} // If a stack is not provided, capture a stack trace. if (!priorStackTrace && !stack) stack = getStackTrace(); stack = filterExtensionStackTrace(stack); if (stack) message += '\n' + stack; // If an asynchronouse stack trace was set, append it. if (priorStackTrace) message += '\n' + priorStackTrace; return message; } function filterExtensionStackTrace(stack) { if (!stack) return ''; // Remove stack frames in the stack trace that weren't associated with the // extension, to not confuse extension developers with internal details. stack = $String.split(stack, '\n'); stack = $Array.filter(stack, function(line) { return $String.indexOf(line, 'chrome-extension://') >= 0; }); return $Array.join(stack, '\n'); } function getStackTrace() { var e = {}; $Error.captureStackTrace(e, getStackTrace); return e.stack; } function getExtensionStackTrace() { return filterExtensionStackTrace(getStackTrace()); } /** * Convert an object to a string. * * @param {Error|*} e - A thrown object (possibly user-supplied). * @param {boolean=} omitType - Whether to try to serialize |e.message| instead * of |e.toString()|. * @return {string} The error message. */ function safeErrorToString(e, omitType) { try { return $String.self(omitType && e.message || e); } catch (e) { // This error is exceptional and could be triggered by // throw {toString: function() { throw 'Haha' } }; return '(cannot get error message)'; } } exports.$set('handle', handle); // |newHandler| A function which matches |handler|. exports.$set('setHandler', function(newHandler) { handler = newHandler; }); exports.$set('safeCallbackApply', safeCallbackApply); exports.$set('getStackTrace', getStackTrace); exports.$set('getExtensionStackTrace', getExtensionStackTrace); exports.$set('safeErrorToString', safeErrorToString); // 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 nativeDeepCopy = requireNative('utils').deepCopy; var logActivity = requireNative('activityLogger'); var exceptionHandler = require('uncaught_exception_handler'); var jsLastError = bindingUtil ? undefined : require('lastError'); function runCallbackWithLastError(name, message, stack, callback, args) { if (bindingUtil) { bindingUtil.runCallbackWithLastError(message, function() { $Function.apply(callback, null, args); }); } else { jsLastError.run(name, message, stack, callback, args); } } /** * An object forEach. Calls |f| with each (key, value) pair of |obj|, using * |self| as the target. * @param {Object} obj The object to iterate over. * @param {function} f The function to call in each iteration. * @param {Object} self The object to use as |this| in each function call. */ function forEach(obj, f, self) { for (var key in obj) { if ($Object.hasOwnProperty(obj, key)) $Function.call(f, self, key, obj[key]); } } /** * Assuming |array_of_dictionaries| is structured like this: * [{id: 1, ... }, {id: 2, ...}, ...], you can use * lookup(array_of_dictionaries, 'id', 2) to get the dictionary with id == 2. * @param {Array>} array_of_dictionaries * @param {string} field * @param {?} value */ function lookup(array_of_dictionaries, field, value) { var filter = function (dict) {return dict[field] == value;}; var matches = $Array.filter(array_of_dictionaries, filter); if (matches.length == 0) { return undefined; } else if (matches.length == 1) { return matches[0] } else { throw new Error("Failed lookup of field '" + field + "' with value '" + value + "'"); } } /** * Sets a property |value| on |obj| with property name |key|. Like * * obj[key] = value; * * but without triggering setters. */ function defineProperty(obj, key, value) { $Object.defineProperty(obj, key, { __proto__: null, configurable: true, enumerable: true, writable: true, value: value, }); } /** * Takes a private class implementation |privateClass| and exposes a subset of * its methods |functions| and properties |properties| and |readonly| to a * public wrapper class that should be passed in. Within bindings code, you can * access the implementation from an instance of the wrapper class using * privates(instance).impl, and from the implementation class you can access * the wrapper using this.wrapper (or implInstance.wrapper if you have another * instance of the implementation class). * * |publicClass| should be a constructor that calls constructPrivate() like so: * * privates(publicClass).constructPrivate(this, arguments); * * @param {function} publicClass The publicly exposed wrapper class. This must * be a named function, and the name appears in stack traces. * @param {Object} privateClass The class implementation. * @param {{superclass: ?Function, * functions: ?Array, * properties: ?Array, * readonly: ?Array}} exposed The names of properties on the * implementation class to be exposed. |superclass| represents the * constructor of the class to be used as the superclass of the exposed * class; |functions| represents the names of functions which should be * delegated to the implementation; |properties| are gettable/settable * properties and |readonly| are read-only properties. */ function expose(publicClass, privateClass, exposed) { $Object.setPrototypeOf(exposed, null); // This should be called by publicClass. privates(publicClass).constructPrivate = function(self, args) { if (!(self instanceof publicClass)) { throw new Error('Please use "new ' + publicClass.name + '"'); } // The "instanceof publicClass" check can easily be spoofed, so we check // whether the private impl is already set before continuing. var privateSelf = privates(self); if ('impl' in privateSelf) { throw new Error('Object ' + publicClass.name + ' is already constructed'); } var privateObj = $Object.create(privateClass.prototype); $Function.apply(privateClass, privateObj, args); privateObj.wrapper = self; privateSelf.impl = privateObj; }; function getPrivateImpl(self) { var impl = privates(self).impl; if (!(impl instanceof privateClass)) { // Either the object is not constructed, or the property descriptor is // used on a target that is not an instance of publicClass. throw new Error('impl is not an instance of ' + privateClass.name); } return impl; } var publicClassPrototype = { // The final prototype will be assigned at the end of this method. __proto__: null, constructor: publicClass, }; if ('functions' in exposed) { $Array.forEach(exposed.functions, function(func) { publicClassPrototype[func] = function() { var impl = getPrivateImpl(this); return $Function.apply(impl[func], impl, arguments); }; }); } if ('properties' in exposed) { $Array.forEach(exposed.properties, function(prop) { $Object.defineProperty(publicClassPrototype, prop, { __proto__: null, enumerable: true, get: function() { return getPrivateImpl(this)[prop]; }, set: function(value) { var impl = getPrivateImpl(this); delete impl[prop]; impl[prop] = value; } }); }); } if ('readonly' in exposed) { $Array.forEach(exposed.readonly, function(readonly) { $Object.defineProperty(publicClassPrototype, readonly, { __proto__: null, enumerable: true, get: function() { return getPrivateImpl(this)[readonly]; }, }); }); } // The prototype properties have been installed. Now we can safely assign an // unsafe prototype and export the class to the public. var superclass = exposed.superclass || $Object.self; $Object.setPrototypeOf(publicClassPrototype, superclass.prototype); publicClass.prototype = publicClassPrototype; return publicClass; } /** * Returns a deep copy of |value|. The copy will have no references to nested * values of |value|. */ function deepCopy(value) { return nativeDeepCopy(value); } // DO NOT USE. This causes problems with safe builtins, and makes migration to // native bindings more difficult. function handleRequestWithPromiseDoNotUse( binding, apiName, methodName, customizedFunction) { var fullName = apiName + '.' + methodName; var extensionId = requireNative('process').GetExtensionId(); binding.setHandleRequest(methodName, function() { logActivity.LogAPICall(extensionId, fullName, $Array.slice(arguments)); var stack = exceptionHandler.getExtensionStackTrace(); var callback = arguments[arguments.length - 1]; var args = $Array.slice(arguments, 0, arguments.length - 1); var keepAlive = require('keep_alive').createKeepAlive(); $Function.apply(customizedFunction, this, args).then(function(result) { if (callback) { exceptionHandler.safeCallbackApply( fullName, {__proto__: null, stack: stack}, callback, [result]); } }).catch(function(error) { if (callback) { var message = exceptionHandler.safeErrorToString(error, true); runCallbackWithLastError(fullName, message, stack, callback); } }).then(function() { keepAlive.close(); }); }); }; exports.$set('forEach', forEach); exports.$set('lookup', lookup); exports.$set('defineProperty', defineProperty); exports.$set('expose', expose); exports.$set('deepCopy', deepCopy); exports.$set('handleRequestWithPromiseDoNotUse', handleRequestWithPromiseDoNotUse); // 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. // This module implements helper objects for the dialog, newwindow, and // permissionrequest events. var MessagingNatives = requireNative('messaging_natives'); var WebViewConstants = require('webViewConstants').WebViewConstants; var WebViewInternal = getInternalApi ? getInternalApi('webViewInternal') : require('webViewInternal').WebViewInternal; var PERMISSION_TYPES = ['media', 'geolocation', 'pointerLock', 'download', 'loadplugin', 'filesystem', 'fullscreen']; // ----------------------------------------------------------------------------- // WebViewActionRequest object. // Default partial implementation of a webview action request. function WebViewActionRequest(webViewImpl, event, webViewEvent, interfaceName) { this.webViewImpl = webViewImpl; this.event = event; this.webViewEvent = webViewEvent; this.interfaceName = interfaceName; this.guestInstanceId = this.webViewImpl.guest.getId(); this.requestId = event.requestId; this.actionTaken = false; // Add on the request information specific to the request type. for (var infoName in this.event.requestInfo) { this.event[infoName] = this.event.requestInfo[infoName]; this.webViewEvent[infoName] = this.event.requestInfo[infoName]; } } // Prevent GuestViewEvents inadvertently inheritng code from the global Object, // allowing a pathway for unintended execution of user code. // TODO(wjmaclean): Use utils.expose() here instead, track down other issues // of Object inheritance. https://crbug.com/701034 WebViewActionRequest.prototype.__proto__ = null; // Performs the default action for the request. WebViewActionRequest.prototype.defaultAction = function() { // Do nothing if the action has already been taken or the requester is // already gone (in which case its guestInstanceId will be stale). if (this.actionTaken || this.guestInstanceId != this.webViewImpl.guest.getId()) { return; } this.actionTaken = true; WebViewInternal.setPermission(this.guestInstanceId, this.requestId, 'default', '', $Function.bind(function(allowed) { if (allowed) { return; } this.showWarningMessage(); }, this)); }; // Called to handle the action request's event. WebViewActionRequest.prototype.handleActionRequestEvent = function() { // Construct the interface object and attach it to |webViewEvent|. var request = this.getInterfaceObject(); this.webViewEvent[this.interfaceName] = request; var defaultPrevented = !this.webViewImpl.dispatchEvent(this.webViewEvent); // Set |webViewEvent| to null to break the circular reference to |request| so // that the garbage collector can eventually collect it. this.webViewEvent = null; if (this.actionTaken) { return; } if (defaultPrevented) { // Track the lifetime of |request| with the garbage collector. var portId = -1; // (hack) there is no Extension Port to release MessagingNatives.BindToGC( request, $Function.bind(this.defaultAction, this), portId); } else { this.defaultAction(); } }; // Displays a warning message when an action request is blocked by default. WebViewActionRequest.prototype.showWarningMessage = function() { window.console.warn(this.WARNING_MSG_REQUEST_BLOCKED); }; // This function ensures that each action is taken at most once. WebViewActionRequest.prototype.validateCall = function() { if (this.actionTaken) { throw new Error(this.ERROR_MSG_ACTION_ALREADY_TAKEN); } this.actionTaken = true; }; // The following are implemented by the specific action request. // Returns the interface object for this action request. WebViewActionRequest.prototype.getInterfaceObject = undefined; // Error/warning messages. WebViewActionRequest.prototype.ERROR_MSG_ACTION_ALREADY_TAKEN = undefined; WebViewActionRequest.prototype.WARNING_MSG_REQUEST_BLOCKED = undefined; // ----------------------------------------------------------------------------- // Dialog object. // Represents a dialog box request (e.g. alert()). function Dialog(webViewImpl, event, webViewEvent) { WebViewActionRequest.call(this, webViewImpl, event, webViewEvent, 'dialog'); this.handleActionRequestEvent(); } Dialog.prototype.__proto__ = WebViewActionRequest.prototype; Dialog.prototype.getInterfaceObject = function() { return { ok: $Function.bind(function(user_input) { this.validateCall(); user_input = user_input || ''; WebViewInternal.setPermission( this.guestInstanceId, this.requestId, 'allow', user_input); }, this), cancel: $Function.bind(function() { this.validateCall(); WebViewInternal.setPermission( this.guestInstanceId, this.requestId, 'deny'); }, this) }; }; Dialog.prototype.showWarningMessage = function() { var VOWELS = ['a', 'e', 'i', 'o', 'u']; var dialogType = this.event.messageType; var article = (VOWELS.indexOf(dialogType.charAt(0)) >= 0) ? 'An' : 'A'; this.WARNING_MSG_REQUEST_BLOCKED = this.WARNING_MSG_REQUEST_BLOCKED. replace('%1', article).replace('%2', dialogType); window.console.warn(this.WARNING_MSG_REQUEST_BLOCKED); }; Dialog.prototype.ERROR_MSG_ACTION_ALREADY_TAKEN = WebViewConstants.ERROR_MSG_DIALOG_ACTION_ALREADY_TAKEN; Dialog.prototype.WARNING_MSG_REQUEST_BLOCKED = WebViewConstants.WARNING_MSG_DIALOG_REQUEST_BLOCKED; // ----------------------------------------------------------------------------- // NewWindow object. // Represents a new window request. function NewWindow(webViewImpl, event, webViewEvent) { WebViewActionRequest.call(this, webViewImpl, event, webViewEvent, 'window'); this.handleActionRequestEvent(); } NewWindow.prototype.__proto__ = WebViewActionRequest.prototype; NewWindow.prototype.getInterfaceObject = function() { return { attach: $Function.bind(function(webview) { this.validateCall(); if (!webview || !webview.tagName || webview.tagName != 'WEBVIEW') { throw new Error(ERROR_MSG_WEBVIEW_EXPECTED); } var webViewImpl = privates(webview).internal; // Update the partition. if (this.event.partition) { webViewImpl.onAttach(this.event.partition); } var attached = webViewImpl.attachWindow$(this.event.windowId); if (!attached) { window.console.error(ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH); } if (this.guestInstanceId != this.webViewImpl.guest.getId()) { // If the opener is already gone, then its guestInstanceId will be // stale. return; } // If the object being passed into attach is not a valid // then we will fail and it will be treated as if the new window // was rejected. The permission API plumbing is used here to clean // up the state created for the new window if attaching fails. WebViewInternal.setPermission(this.guestInstanceId, this.requestId, attached ? 'allow' : 'deny'); }, this), discard: $Function.bind(function() { this.validateCall(); if (!this.guestInstanceId) { // If the opener is already gone, then we won't have its // guestInstanceId. return; } WebViewInternal.setPermission( this.guestInstanceId, this.requestId, 'deny'); }, this) }; }; NewWindow.prototype.ERROR_MSG_ACTION_ALREADY_TAKEN = WebViewConstants.ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN; NewWindow.prototype.WARNING_MSG_REQUEST_BLOCKED = WebViewConstants.WARNING_MSG_NEWWINDOW_REQUEST_BLOCKED; // ----------------------------------------------------------------------------- // PermissionRequest object. // Represents a permission request (e.g. to access the filesystem). function PermissionRequest(webViewImpl, event, webViewEvent) { WebViewActionRequest.call(this, webViewImpl, event, webViewEvent, 'request'); if (!this.validPermissionCheck()) { return; } this.handleActionRequestEvent(); } PermissionRequest.prototype.__proto__ = WebViewActionRequest.prototype; PermissionRequest.prototype.allow = function() { this.validateCall(); WebViewInternal.setPermission(this.guestInstanceId, this.requestId, 'allow'); }; PermissionRequest.prototype.deny = function() { this.validateCall(); WebViewInternal.setPermission(this.guestInstanceId, this.requestId, 'deny'); }; PermissionRequest.prototype.getInterfaceObject = function() { var request = { allow: $Function.bind(this.allow, this), deny: $Function.bind(this.deny, this) }; // Add on the request information specific to the request type. for (var infoName in this.event.requestInfo) { request[infoName] = this.event.requestInfo[infoName]; } return $Object.freeze(request); }; PermissionRequest.prototype.showWarningMessage = function() { window.console.warn( this.WARNING_MSG_REQUEST_BLOCKED.replace('%1', this.event.permission)); }; // Checks that the requested permission is valid. Returns true if valid. PermissionRequest.prototype.validPermissionCheck = function() { if (PERMISSION_TYPES.indexOf(this.event.permission) < 0) { // The permission type is not allowed. Trigger the default response. this.defaultAction(); return false; } return true; }; PermissionRequest.prototype.ERROR_MSG_ACTION_ALREADY_TAKEN = WebViewConstants.ERROR_MSG_PERMISSION_ACTION_ALREADY_TAKEN; PermissionRequest.prototype.WARNING_MSG_REQUEST_BLOCKED = WebViewConstants.WARNING_MSG_PERMISSION_REQUEST_BLOCKED; // ----------------------------------------------------------------------------- // FullscreenPermissionRequest object. // Represents a fullscreen permission request. function FullscreenPermissionRequest(webViewImpl, event, webViewEvent) { PermissionRequest.call(this, webViewImpl, event, webViewEvent); } FullscreenPermissionRequest.prototype.__proto__ = PermissionRequest.prototype; FullscreenPermissionRequest.prototype.allow = function() { PermissionRequest.prototype.allow.call(this); // Now make the element go fullscreen. this.webViewImpl.makeElementFullscreen(); }; // ----------------------------------------------------------------------------- var WebViewActionRequests = { WebViewActionRequest: WebViewActionRequest, Dialog: Dialog, NewWindow: NewWindow, PermissionRequest: PermissionRequest, FullscreenPermissionRequest: FullscreenPermissionRequest }; // Exports. exports.$set('WebViewActionRequests', WebViewActionRequests); // 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. // This module implements the public-facing API functions for the tag. var WebViewInternal = getInternalApi ? getInternalApi('webViewInternal') : require('webViewInternal').WebViewInternal; var WebViewImpl = require('webView').WebViewImpl; // An array of 's public-facing API methods. Methods without custom // implementations will be given default implementations that call into the // internal API method with the same name in |WebViewInternal|. For example, a // method called 'someApiMethod' would be given the following default // implementation: // // WebViewImpl.prototype.someApiMethod = function(var_args) { // if (!this.guest.getId()) { // return false; // } // var args = $Array.concat([this.guest.getId()], $Array.slice(arguments)); // $Function.apply(WebViewInternal.someApiMethod, null, args); // return true; // }; // // These default implementations come from createDefaultApiMethod() in // web_view.js. var WEB_VIEW_API_METHODS = [ // Add content scripts for the guest page. 'addContentScripts', // Navigates to the previous history entry. 'back', // Returns whether there is a previous history entry to navigate to. 'canGoBack', // Returns whether there is a subsequent history entry to navigate to. 'canGoForward', // Captures the visible region of the WebView contents into a bitmap. 'captureVisibleRegion', // Clears browsing data for the WebView partition. 'clearData', // Injects JavaScript code into the guest page. 'executeScript', // Initiates a find-in-page request. 'find', // Navigates to the subsequent history entry. 'forward', // Returns audio state. 'getAudioState', // Returns Chrome's internal process ID for the guest web page's current // process. 'getProcessId', // Returns the user agent string used by the webview for guest page requests. 'getUserAgent', // Gets the current zoom factor. 'getZoom', // Gets the current zoom mode of the webview. 'getZoomMode', // Navigates to a history entry using a history index relative to the current // navigation. 'go', // Injects CSS into the guest page. 'insertCSS', // Returns whether audio is muted. 'isAudioMuted', // Indicates whether or not the webview's user agent string has been // overridden. 'isUserAgentOverridden', // Loads a data URL with a specified base URL used for relative links. // Optionally, a virtual URL can be provided to be shown to the user instead // of the data URL. 'loadDataWithBaseUrl', // Prints the contents of the webview. 'print', // Removes content scripts for the guest page. 'removeContentScripts', // Reloads the current top-level page. 'reload', // Set audio mute. 'setAudioMuted', // Override the user agent string used by the webview for guest page requests. 'setUserAgentOverride', // Changes the zoom factor of the page. 'setZoom', // Changes the zoom mode of the webview. 'setZoomMode', // Stops loading the current navigation if one is in progress. 'stop', // Ends the current find session. 'stopFinding', // Forcibly kills the guest web page's renderer process. 'terminate' ]; // ----------------------------------------------------------------------------- // Custom API method implementations. WebViewImpl.prototype.addContentScripts = function(rules) { return WebViewInternal.addContentScripts(this.viewInstanceId, rules); }; WebViewImpl.prototype.back = function(callback) { return this.go(-1, callback); }; WebViewImpl.prototype.canGoBack = function() { return this.entryCount > 1 && this.currentEntryIndex > 0; }; WebViewImpl.prototype.canGoForward = function() { return this.currentEntryIndex >= 0 && this.currentEntryIndex < (this.entryCount - 1); }; WebViewImpl.prototype.executeScript = function(var_args) { return this.executeCode(WebViewInternal.executeScript, $Array.slice(arguments)); }; WebViewImpl.prototype.forward = function(callback) { return this.go(1, callback); }; WebViewImpl.prototype.getProcessId = function() { return this.processId; }; WebViewImpl.prototype.getUserAgent = function() { return this.userAgentOverride || navigator.userAgent; }; WebViewImpl.prototype.insertCSS = function(var_args) { return this.executeCode(WebViewInternal.insertCSS, $Array.slice(arguments)); }; WebViewImpl.prototype.isUserAgentOverridden = function() { return !!this.userAgentOverride && this.userAgentOverride != navigator.userAgent; }; WebViewImpl.prototype.loadDataWithBaseUrl = function( dataUrl, baseUrl, virtualUrl) { if (!this.guest.getId()) { return; } WebViewInternal.loadDataWithBaseUrl( this.guest.getId(), dataUrl, baseUrl, virtualUrl, function() { // Report any errors. if (chrome.runtime.lastError != undefined) { window.console.error( 'Error while running webview.loadDataWithBaseUrl: ' + chrome.runtime.lastError.message); } }); }; WebViewImpl.prototype.print = function() { return this.executeScript({code: 'window.print();'}); }; WebViewImpl.prototype.removeContentScripts = function(names) { return WebViewInternal.removeContentScripts(this.viewInstanceId, names); }; WebViewImpl.prototype.setUserAgentOverride = function(userAgentOverride) { this.userAgentOverride = userAgentOverride; if (!this.guest.getId()) { // If we are not attached yet, then we will pick up the user agent on // attachment. return false; } WebViewInternal.overrideUserAgent(this.guest.getId(), userAgentOverride); return true; }; WebViewImpl.prototype.setZoom = function(zoomFactor, callback) { if (!this.guest.getId()) { this.cachedZoomFactor = zoomFactor; return false; } this.cachedZoomFactor = 1; WebViewInternal.setZoom(this.guest.getId(), zoomFactor, callback); return true; }; // ----------------------------------------------------------------------------- WebViewImpl.getApiMethods = function() { return WEB_VIEW_API_METHODS; }; // 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. // This module implements the attributes of the tag. var GuestViewAttributes = require('guestViewAttributes').GuestViewAttributes; var WebViewConstants = require('webViewConstants').WebViewConstants; var WebViewImpl = require('webView').WebViewImpl; var WebViewInternal = getInternalApi ? getInternalApi('webViewInternal') : require('webViewInternal').WebViewInternal; // ----------------------------------------------------------------------------- // AllowScalingAttribute object. // Attribute that specifies whether scaling is allowed in the webview. function AllowScalingAttribute(view) { GuestViewAttributes.BooleanAttribute.call( this, WebViewConstants.ATTRIBUTE_ALLOWSCALING, view); } AllowScalingAttribute.prototype.__proto__ = GuestViewAttributes.BooleanAttribute.prototype; AllowScalingAttribute.prototype.handleMutation = function(oldValue, newValue) { if (!this.view.guest.getId()) return; WebViewInternal.setAllowScaling(this.view.guest.getId(), this.getValue()); }; // ----------------------------------------------------------------------------- // AllowTransparencyAttribute object. // Attribute that specifies whether transparency is allowed in the webview. function AllowTransparencyAttribute(view) { GuestViewAttributes.BooleanAttribute.call( this, WebViewConstants.ATTRIBUTE_ALLOWTRANSPARENCY, view); } AllowTransparencyAttribute.prototype.__proto__ = GuestViewAttributes.BooleanAttribute.prototype; AllowTransparencyAttribute.prototype.handleMutation = function(oldValue, newValue) { if (!this.view.guest.getId()) return; WebViewInternal.setAllowTransparency(this.view.guest.getId(), this.getValue()); }; // ----------------------------------------------------------------------------- // AutosizeDimensionAttribute object. // Attribute used to define the demension limits of autosizing. function AutosizeDimensionAttribute(name, view) { GuestViewAttributes.IntegerAttribute.call(this, name, view); } AutosizeDimensionAttribute.prototype.__proto__ = GuestViewAttributes.IntegerAttribute.prototype; AutosizeDimensionAttribute.prototype.handleMutation = function( oldValue, newValue) { if (!this.view.guest.getId()) return; this.view.guest.setSize({ 'enableAutoSize': this.view.attributes[ WebViewConstants.ATTRIBUTE_AUTOSIZE].getValue(), 'min': { 'width': this.view.attributes[ WebViewConstants.ATTRIBUTE_MINWIDTH].getValue(), 'height': this.view.attributes[ WebViewConstants.ATTRIBUTE_MINHEIGHT].getValue() }, 'max': { 'width': this.view.attributes[ WebViewConstants.ATTRIBUTE_MAXWIDTH].getValue(), 'height': this.view.attributes[ WebViewConstants.ATTRIBUTE_MAXHEIGHT].getValue() } }); return; }; // ----------------------------------------------------------------------------- // AutosizeAttribute object. // Attribute that specifies whether the webview should be autosized. function AutosizeAttribute(view) { GuestViewAttributes.BooleanAttribute.call( this, WebViewConstants.ATTRIBUTE_AUTOSIZE, view); } AutosizeAttribute.prototype.__proto__ = GuestViewAttributes.BooleanAttribute.prototype; AutosizeAttribute.prototype.handleMutation = AutosizeDimensionAttribute.prototype.handleMutation; // ----------------------------------------------------------------------------- // NameAttribute object. // Attribute that sets the guest content's window.name object. function NameAttribute(view) { GuestViewAttributes.Attribute.call( this, WebViewConstants.ATTRIBUTE_NAME, view); } NameAttribute.prototype.__proto__ = GuestViewAttributes.Attribute.prototype NameAttribute.prototype.handleMutation = function(oldValue, newValue) { oldValue = oldValue || ''; newValue = newValue || ''; if (oldValue === newValue || !this.view.guest.getId()) return; WebViewInternal.setName(this.view.guest.getId(), newValue); }; NameAttribute.prototype.setValue = function(value) { value = value || ''; if (value === '') this.view.element.removeAttribute(this.name); else this.view.element.setAttribute(this.name, value); }; // ----------------------------------------------------------------------------- // PartitionAttribute object. // Attribute representing the state of the storage partition. function PartitionAttribute(view) { GuestViewAttributes.Attribute.call( this, WebViewConstants.ATTRIBUTE_PARTITION, view); this.validPartitionId = true; } PartitionAttribute.prototype.__proto__ = GuestViewAttributes.Attribute.prototype; PartitionAttribute.prototype.handleMutation = function(oldValue, newValue) { newValue = newValue || ''; // The partition cannot change if the webview has already navigated. if (!this.view.attributes[ WebViewConstants.ATTRIBUTE_SRC].beforeFirstNavigation) { window.console.error(WebViewConstants.ERROR_MSG_ALREADY_NAVIGATED); this.setValueIgnoreMutation(oldValue); return; } if (newValue == 'persist:') { this.validPartitionId = false; window.console.error( WebViewConstants.ERROR_MSG_INVALID_PARTITION_ATTRIBUTE); } }; PartitionAttribute.prototype.detach = function() { this.validPartitionId = true; }; // ----------------------------------------------------------------------------- // SrcAttribute object. // Attribute that handles the location and navigation of the webview. function SrcAttribute(view) { GuestViewAttributes.Attribute.call( this, WebViewConstants.ATTRIBUTE_SRC, view); this.setupMutationObserver(); this.beforeFirstNavigation = true; } SrcAttribute.prototype.__proto__ = GuestViewAttributes.Attribute.prototype; SrcAttribute.prototype.setValueIgnoreMutation = function(value) { GuestViewAttributes.Attribute.prototype.setValueIgnoreMutation.call( this, value); // takeRecords() is needed to clear queued up src mutations. Without it, it is // possible for this change to get picked up asyncronously by src's mutation // observer |observer|, and then get handled even though we do not want to // handle this mutation. this.observer.takeRecords(); } SrcAttribute.prototype.handleMutation = function(oldValue, newValue) { // Once we have navigated, we don't allow clearing the src attribute. // Once enters a navigated state, it cannot return to a // placeholder state. if (!newValue && oldValue) { // src attribute changes normally initiate a navigation. We suppress // the next src attribute handler call to avoid reloading the page // on every guest-initiated navigation. this.setValueIgnoreMutation(oldValue); return; } this.parse(); }; SrcAttribute.prototype.attach = function() { this.parse(); }; SrcAttribute.prototype.detach = function() { this.beforeFirstNavigation = true; }; // The purpose of this mutation observer is to catch assignment to the src // attribute without any changes to its value. This is useful in the case // where the webview guest has crashed and navigating to the same address // spawns off a new process. SrcAttribute.prototype.setupMutationObserver = function() { this.observer = new MutationObserver($Function.bind(function(mutations) { $Array.forEach(mutations, $Function.bind(function(mutation) { var oldValue = mutation.oldValue; var newValue = this.getValue(); if (oldValue != newValue) { return; } this.handleMutation(oldValue, newValue); }, this)); }, this)); var params = { attributes: true, attributeOldValue: true, attributeFilter: [this.name] }; this.observer.observe(this.view.element, params); }; SrcAttribute.prototype.parse = function() { if (!this.view.elementAttached || !this.view.attributes[ WebViewConstants.ATTRIBUTE_PARTITION].validPartitionId || !this.getValue()) { return; } if (!this.view.guest.getId()) { if (this.beforeFirstNavigation) { this.beforeFirstNavigation = false; this.view.createGuest(); } return; } WebViewInternal.navigate(this.view.guest.getId(), this.getValue()); }; // ----------------------------------------------------------------------------- // Sets up all of the webview attributes. WebViewImpl.prototype.setupAttributes = function() { this.attributes[WebViewConstants.ATTRIBUTE_ALLOWSCALING] = new AllowScalingAttribute(this); this.attributes[WebViewConstants.ATTRIBUTE_ALLOWTRANSPARENCY] = new AllowTransparencyAttribute(this); this.attributes[WebViewConstants.ATTRIBUTE_AUTOSIZE] = new AutosizeAttribute(this); this.attributes[WebViewConstants.ATTRIBUTE_NAME] = new NameAttribute(this); this.attributes[WebViewConstants.ATTRIBUTE_PARTITION] = new PartitionAttribute(this); this.attributes[WebViewConstants.ATTRIBUTE_SRC] = new SrcAttribute(this); var autosizeAttributes = [WebViewConstants.ATTRIBUTE_MAXHEIGHT, WebViewConstants.ATTRIBUTE_MAXWIDTH, WebViewConstants.ATTRIBUTE_MINHEIGHT, WebViewConstants.ATTRIBUTE_MINWIDTH]; for (var i = 0; autosizeAttributes[i]; ++i) { this.attributes[autosizeAttributes[i]] = new AutosizeDimensionAttribute(autosizeAttributes[i], this); } }; // Copyright (c) 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. // This module contains constants used in webview. // Container for the webview constants. var WebViewConstants = { // Attributes. ATTRIBUTE_ALLOWTRANSPARENCY: 'allowtransparency', ATTRIBUTE_ALLOWSCALING: 'allowscaling', ATTRIBUTE_AUTOSIZE: 'autosize', ATTRIBUTE_MAXHEIGHT: 'maxheight', ATTRIBUTE_MAXWIDTH: 'maxwidth', ATTRIBUTE_MINHEIGHT: 'minheight', ATTRIBUTE_MINWIDTH: 'minwidth', ATTRIBUTE_NAME: 'name', ATTRIBUTE_PARTITION: 'partition', ATTRIBUTE_SRC: 'src', // Error/warning messages. ERROR_MSG_ALREADY_NAVIGATED: ': ' + 'The object has already navigated, so its partition cannot be changed.', ERROR_MSG_CANNOT_INJECT_SCRIPT: ': ' + 'Script cannot be injected into content until the page has loaded.', ERROR_MSG_DIALOG_ACTION_ALREADY_TAKEN: ': ' + 'An action has already been taken for this "dialog" event.', ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN: ': ' + 'An action has already been taken for this "newwindow" event.', ERROR_MSG_PERMISSION_ACTION_ALREADY_TAKEN: ': ' + 'Permission has already been decided for this "permissionrequest" event.', ERROR_MSG_INVALID_PARTITION_ATTRIBUTE: ': ' + 'Invalid partition attribute.', WARNING_MSG_DIALOG_REQUEST_BLOCKED: ': %1 %2 dialog was blocked.', WARNING_MSG_NEWWINDOW_REQUEST_BLOCKED: ': A new window was blocked.', WARNING_MSG_PERMISSION_REQUEST_BLOCKED: ': ' + 'The permission request for "%1" has been denied.' }; exports.$set('WebViewConstants', $Object.freeze(WebViewConstants)); // 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. // Event management for WebView. var CreateEvent = require('guestViewEvents').CreateEvent; var DCHECK = requireNative('logging').DCHECK; var DeclarativeWebRequestSchema = requireNative('schema_registry').GetSchema('declarativeWebRequest'); var GuestViewEvents = require('guestViewEvents').GuestViewEvents; var GuestViewInternalNatives = requireNative('guest_view_internal'); var IdGenerator = requireNative('id_generator'); var WebRequestEvent = require('webRequestEvent').WebRequestEvent; var WebRequestSchema = requireNative('schema_registry').GetSchema('webRequest'); var WebViewActionRequests = require('webViewActionRequests').WebViewActionRequests; var WebRequestMessageEvent = CreateEvent('webViewInternal.onMessage'); function WebViewEvents(webViewImpl) { GuestViewEvents.call(this, webViewImpl); this.setupWebRequestEvents(); this.view.maybeSetupContextMenus(); } var jsEvent; function createCustomDeclarativeEvent(name, schema, options, webviewId) { if (bindingUtil) { return bindingUtil.createCustomDeclarativeEvent( name, options.actions, options.conditions, webviewId || 0); } if (!jsEvent) jsEvent = require('event_bindings').Event; return new jsEvent(name, schema, options, webviewId); } function createCustomEvent(name, schema, options) { var supportsLazyListeners = false; if (bindingUtil) { return bindingUtil.createCustomEvent(name, undefined, false, supportsLazyListeners); } if (!jsEvent) jsEvent = require('event_bindings').Event; if (!options) options = {__proto__: null, supportsLazyListeners: false}; DCHECK(!options.supportsLazyListeners); return new jsEvent(name, schema, options); } function createOnMessageEvent(name, schema, options, webviewId) { var subEventName = name + '/' + IdGenerator.GetNextId(); var newEvent = createCustomEvent(subEventName, schema, options); var view = GuestViewInternalNatives.GetViewFromID(webviewId || 0); if (view) { view.events.addScopedListener( WebRequestMessageEvent, $Function.bind(function() { // Re-dispatch to subEvent's listeners. $Function.apply(newEvent.dispatch, newEvent, $Array.slice(arguments)); }, newEvent), {instanceId: webviewId || 0}); } return newEvent; } WebViewEvents.prototype.__proto__ = GuestViewEvents.prototype; // A dictionary of extension events to be listened for. This // dictionary augments |GuestViewEvents.EVENTS| in guest_view_events.js. See the // documentation there for details. WebViewEvents.EVENTS = { 'audiostatechanged': { evt: CreateEvent('webViewInternal.onAudioStateChanged'), fields: ['audible'] }, 'close': { evt: CreateEvent('webViewInternal.onClose') }, 'consolemessage': { evt: CreateEvent('webViewInternal.onConsoleMessage'), fields: ['level', 'message', 'line', 'sourceId'] }, 'contentload': { evt: CreateEvent('webViewInternal.onContentLoad') }, 'dialog': { cancelable: true, evt: CreateEvent('webViewInternal.onDialog'), fields: ['defaultPromptText', 'messageText', 'messageType', 'url'], handler: 'handleDialogEvent' }, 'droplink': { evt: CreateEvent('webViewInternal.onDropLink'), fields: ['url'] }, 'exit': { evt: CreateEvent('webViewInternal.onExit'), fields: ['processId', 'reason'] }, 'exitfullscreen': { evt: CreateEvent('webViewInternal.onExitFullscreen'), fields: ['url'], handler: 'handleFullscreenExitEvent', internal: true }, 'findupdate': { evt: CreateEvent('webViewInternal.onFindReply'), fields: [ 'searchText', 'numberOfMatches', 'activeMatchOrdinal', 'selectionRect', 'canceled', 'finalUpdate' ] }, 'framenamechanged': { evt: CreateEvent('webViewInternal.onFrameNameChanged'), handler: 'handleFrameNameChangedEvent', internal: true }, 'loadabort': { cancelable: true, evt: CreateEvent('webViewInternal.onLoadAbort'), fields: ['url', 'isTopLevel', 'code', 'reason'], handler: 'handleLoadAbortEvent' }, 'loadcommit': { evt: CreateEvent('webViewInternal.onLoadCommit'), fields: ['url', 'isTopLevel'], handler: 'handleLoadCommitEvent' }, 'loadprogress': { evt: CreateEvent('webViewInternal.onLoadProgress'), fields: ['url', 'progress'] }, 'loadredirect': { evt: CreateEvent('webViewInternal.onLoadRedirect'), fields: ['isTopLevel', 'oldUrl', 'newUrl'] }, 'loadstart': { evt: CreateEvent('webViewInternal.onLoadStart'), fields: ['url', 'isTopLevel'] }, 'loadstop': { evt: CreateEvent('webViewInternal.onLoadStop') }, 'newwindow': { cancelable: true, evt: CreateEvent('webViewInternal.onNewWindow'), fields: [ 'initialHeight', 'initialWidth', 'targetUrl', 'windowOpenDisposition', 'name' ], handler: 'handleNewWindowEvent' }, 'permissionrequest': { cancelable: true, evt: CreateEvent('webViewInternal.onPermissionRequest'), fields: [ 'identifier', 'lastUnlockedBySelf', 'name', 'permission', 'requestMethod', 'url', 'userGesture' ], handler: 'handlePermissionEvent' }, 'responsive': { evt: CreateEvent('webViewInternal.onResponsive'), fields: ['processId'] }, 'sizechanged': { evt: CreateEvent('webViewInternal.onSizeChanged'), fields: ['oldHeight', 'oldWidth', 'newHeight', 'newWidth'], handler: 'handleSizeChangedEvent' }, 'unresponsive': { evt: CreateEvent('webViewInternal.onUnresponsive'), fields: ['processId'] }, 'zoomchange': { evt: CreateEvent('webViewInternal.onZoomChange'), fields: ['oldZoomFactor', 'newZoomFactor'] } }; WebViewEvents.prototype.setupWebRequestEvents = function() { var request = {}; var createWebRequestEvent = $Function.bind(function(webRequestEvent) { return this.weakWrapper(function() { if (!this[webRequestEvent.name]) { this[webRequestEvent.name] = new WebRequestEvent( 'webViewInternal.' + webRequestEvent.name, webRequestEvent.parameters, webRequestEvent.extraParameters, webRequestEvent.options, this.view.viewInstanceId); } return this[webRequestEvent.name]; }); }, this); var createDeclarativeWebRequestEvent = $Function.bind(function(webRequestEvent) { return this.weakWrapper(function() { if (!this[webRequestEvent.name]) { var newEvent; var eventName = 'webViewInternal.declarativeWebRequest.' + webRequestEvent.name; if (webRequestEvent.name === 'onMessage') { // The onMessage event gets a special event type because we want the // listener to fire only for messages targeted for this particular // . newEvent = createOnMessageEvent(eventName, webRequestEvent.parameters, webRequestEvent.options, this.view.viewInstanceId); } else { newEvent = createCustomDeclarativeEvent(eventName, webRequestEvent.parameters, webRequestEvent.options, this.view.viewInstanceId); } this[webRequestEvent.name] = newEvent; } return this[webRequestEvent.name]; }); }, this); for (var i = 0; i < DeclarativeWebRequestSchema.events.length; ++i) { var eventSchema = DeclarativeWebRequestSchema.events[i]; var webRequestEvent = createDeclarativeWebRequestEvent(eventSchema); Object.defineProperty( request, eventSchema.name, { get: webRequestEvent, enumerable: true } ); } // Populate the WebRequest events from the API definition. for (var i = 0; i < WebRequestSchema.events.length; ++i) { var webRequestEvent = createWebRequestEvent(WebRequestSchema.events[i]); Object.defineProperty( request, WebRequestSchema.events[i].name, { get: webRequestEvent, enumerable: true } ); } this.view.setRequestPropertyOnWebViewElement(request); }; WebViewEvents.prototype.getEvents = function() { return WebViewEvents.EVENTS; }; WebViewEvents.prototype.handleDialogEvent = function(event, eventName) { var webViewEvent = this.makeDomEvent(event, eventName); new WebViewActionRequests.Dialog(this.view, event, webViewEvent); }; WebViewEvents.prototype.handleFrameNameChangedEvent = function(event) { this.view.onFrameNameChanged(event.name); }; WebViewEvents.prototype.handleFullscreenExitEvent = function(event, eventName) { document.webkitCancelFullScreen(); }; WebViewEvents.prototype.handleLoadAbortEvent = function(event, eventName) { var showWarningMessage = function(code, reason) { var WARNING_MSG_LOAD_ABORTED = ': ' + 'The load has aborted with error %1: %2.'; window.console.warn( WARNING_MSG_LOAD_ABORTED.replace('%1', code).replace('%2', reason)); }; var webViewEvent = this.makeDomEvent(event, eventName); if (this.view.dispatchEvent(webViewEvent)) { showWarningMessage(event.code, event.reason); } }; WebViewEvents.prototype.handleLoadCommitEvent = function(event, eventName) { this.view.onLoadCommit(event.baseUrlForDataUrl, event.currentEntryIndex, event.entryCount, event.processId, event.url, event.isTopLevel); var webViewEvent = this.makeDomEvent(event, eventName); this.view.dispatchEvent(webViewEvent); }; WebViewEvents.prototype.handleNewWindowEvent = function(event, eventName) { var webViewEvent = this.makeDomEvent(event, eventName); new WebViewActionRequests.NewWindow(this.view, event, webViewEvent); }; WebViewEvents.prototype.handlePermissionEvent = function(event, eventName) { var webViewEvent = this.makeDomEvent(event, eventName); if (event.permission === 'fullscreen') { new WebViewActionRequests.FullscreenPermissionRequest( this.view, event, webViewEvent); } else { new WebViewActionRequests.PermissionRequest(this.view, event, webViewEvent); } }; WebViewEvents.prototype.handleSizeChangedEvent = function(event, eventName) { var webViewEvent = this.makeDomEvent(event, eventName); this.view.onSizeChanged(webViewEvent); }; // Exports. exports.$set('WebViewEvents', WebViewEvents); // 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. if (!apiBridge) { exports.$set( 'WebViewInternal', require('binding').Binding.create('webViewInternal').generate()); } // 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. // This module implements WebView () as a custom element that wraps a // BrowserPlugin object element. The object element is hidden within // the shadow DOM of the WebView element. var DocumentNatives = requireNative('document_natives'); var GuestView = require('guestView').GuestView; var GuestViewContainer = require('guestViewContainer').GuestViewContainer; var GuestViewInternalNatives = requireNative('guest_view_internal'); var WebViewConstants = require('webViewConstants').WebViewConstants; var WebViewEvents = require('webViewEvents').WebViewEvents; var WebViewInternal = getInternalApi ? getInternalApi('webViewInternal') : require('webViewInternal').WebViewInternal; // Represents the internal state of . function WebViewImpl(webviewElement) { GuestViewContainer.call(this, webviewElement, 'webview'); this.cachedZoom = 1; this.setupElementProperties(); new WebViewEvents(this, this.viewInstanceId); } WebViewImpl.prototype.__proto__ = GuestViewContainer.prototype; WebViewImpl.VIEW_TYPE = 'WebView'; // Add extra functionality to |this.element|. WebViewImpl.setupElement = function(proto) { // Public-facing API methods. var apiMethods = WebViewImpl.getApiMethods(); // Create default implementations for undefined API methods. var createDefaultApiMethod = function(m) { return function(var_args) { if (!this.guest.getId()) { return false; } var args = $Array.concat([this.guest.getId()], $Array.slice(arguments)); $Function.apply(WebViewInternal[m], null, args); return true; }; }; for (var i = 0; i != apiMethods.length; ++i) { if (WebViewImpl.prototype[apiMethods[i]] == undefined) { WebViewImpl.prototype[apiMethods[i]] = createDefaultApiMethod(apiMethods[i]); } } // Forward proto.foo* method calls to WebViewImpl.foo*. GuestViewContainer.forwardApiMethods(proto, apiMethods); }; // Initiates navigation once the element is attached to the DOM. WebViewImpl.prototype.onElementAttached = function() { // Mark all attributes as dirty on attachment. for (var i in this.attributes) { this.attributes[i].dirty = true; } for (var i in this.attributes) { this.attributes[i].attach(); } }; // Resets some state upon detaching element from the DOM. WebViewImpl.prototype.onElementDetached = function() { this.guest.destroy(); for (var i in this.attributes) { this.attributes[i].dirty = false; } for (var i in this.attributes) { this.attributes[i].detach(); } }; // Sets the .request property. WebViewImpl.prototype.setRequestPropertyOnWebViewElement = function(request) { Object.defineProperty( this.element, 'request', { value: request, enumerable: true } ); }; WebViewImpl.prototype.setupElementProperties = function() { // We cannot use {writable: true} property descriptor because we want a // dynamic getter value. Object.defineProperty(this.element, 'contentWindow', { get: $Function.bind(function() { return this.guest.getContentWindow(); }, this), // No setter. enumerable: true }); }; WebViewImpl.prototype.onSizeChanged = function(webViewEvent) { var newWidth = webViewEvent.newWidth; var newHeight = webViewEvent.newHeight; var element = this.element; var width = element.offsetWidth; var height = element.offsetHeight; // Check the current bounds to make sure we do not resize // outside of current constraints. var maxWidth = this.attributes[ WebViewConstants.ATTRIBUTE_MAXWIDTH].getValue() || width; var minWidth = this.attributes[ WebViewConstants.ATTRIBUTE_MINWIDTH].getValue() || width; var maxHeight = this.attributes[ WebViewConstants.ATTRIBUTE_MAXHEIGHT].getValue() || height; var minHeight = this.attributes[ WebViewConstants.ATTRIBUTE_MINHEIGHT].getValue() || height; minWidth = Math.min(minWidth, maxWidth); minHeight = Math.min(minHeight, maxHeight); if (!this.attributes[WebViewConstants.ATTRIBUTE_AUTOSIZE].getValue() || (newWidth >= minWidth && newWidth <= maxWidth && newHeight >= minHeight && newHeight <= maxHeight)) { element.style.width = newWidth + 'px'; element.style.height = newHeight + 'px'; // Only fire the DOM event if the size of the has actually // changed. this.dispatchEvent(webViewEvent); } }; WebViewImpl.prototype.createGuest = function() { this.guest.create(this.buildParams(), $Function.bind(function() { this.attachWindow$(); }, this)); }; WebViewImpl.prototype.onFrameNameChanged = function(name) { this.attributes[WebViewConstants.ATTRIBUTE_NAME].setValueIgnoreMutation(name); }; // Updates state upon loadcommit. WebViewImpl.prototype.onLoadCommit = function( baseUrlForDataUrl, currentEntryIndex, entryCount, processId, url, isTopLevel) { this.baseUrlForDataUrl = baseUrlForDataUrl; this.currentEntryIndex = currentEntryIndex; this.entryCount = entryCount; this.processId = processId; if (isTopLevel) { // Touching the src attribute triggers a navigation. To avoid // triggering a page reload on every guest-initiated navigation, // we do not handle this mutation. this.attributes[ WebViewConstants.ATTRIBUTE_SRC].setValueIgnoreMutation(url); } }; WebViewImpl.prototype.onAttach = function(storagePartitionId) { this.attributes[WebViewConstants.ATTRIBUTE_PARTITION].setValueIgnoreMutation( storagePartitionId); }; WebViewImpl.prototype.buildContainerParams = function() { var params = { 'initialZoomFactor': this.cachedZoomFactor, 'userAgentOverride': this.userAgentOverride }; for (var i in this.attributes) { var value = this.attributes[i].getValueIfDirty(); if (value) params[i] = value; } return params; }; WebViewImpl.prototype.attachWindow$ = function(opt_guestInstanceId) { // If |opt_guestInstanceId| was provided, then a different existing guest is // being attached to this webview, and the current one will get destroyed. if (opt_guestInstanceId) { if (this.guest.getId() == opt_guestInstanceId) { return true; } this.guest.destroy(); this.guest = new GuestView('webview', opt_guestInstanceId); } return GuestViewContainer.prototype.attachWindow$.call(this); }; // Shared implementation of executeScript() and insertCSS(). WebViewImpl.prototype.executeCode = function(func, args) { if (!this.guest.getId()) { window.console.error(WebViewConstants.ERROR_MSG_CANNOT_INJECT_SCRIPT); return false; } var webviewSrc = this.attributes[WebViewConstants.ATTRIBUTE_SRC].getValue(); if (this.baseUrlForDataUrl) { webviewSrc = this.baseUrlForDataUrl; } args = $Array.concat([this.guest.getId(), webviewSrc], $Array.slice(args)); $Function.apply(func, null, args); return true; } // Requests the element wihtin the embedder to enter fullscreen. WebViewImpl.prototype.makeElementFullscreen = function() { GuestViewInternalNatives.RunWithGesture($Function.bind(function() { this.element.webkitRequestFullScreen(); }, this)); }; // Implemented when the ChromeWebView API is available. WebViewImpl.prototype.maybeSetupContextMenus = function() {}; GuestViewContainer.registerElement(WebViewImpl); // Exports. exports.$set('WebViewImpl', WebViewImpl); // 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. // Custom binding for the chrome.app.runtime API. var binding = apiBridge || require('binding').Binding.create('app.runtime'); var AppViewGuestInternal; // appViewGuestInternal isn't available in lock screen contexts. if (requireNative('v8_context').GetAvailability('appViewGuestInternal'). is_available) { AppViewGuestInternal = getInternalApi ? getInternalApi('appViewGuestInternal') : require('binding').Binding.create('appViewGuestInternal').generate(); } var registerArgumentMassager = bindingUtil ? $Function.bind(bindingUtil.registerEventArgumentMassager, bindingUtil) : require('event_bindings').registerArgumentMassager; var fileSystemHelpers = requireNative('file_system_natives'); var GetIsolatedFileSystem = fileSystemHelpers.GetIsolatedFileSystem; var entryIdManager = require('entryIdManager'); if (AppViewGuestInternal) { registerArgumentMassager('app.runtime.onEmbedRequested', function(args, dispatch) { var appEmbeddingRequest = args[0]; var id = appEmbeddingRequest.guestInstanceId; delete appEmbeddingRequest.guestInstanceId; appEmbeddingRequest.allow = function(url) { AppViewGuestInternal.attachFrame(url, id); }; appEmbeddingRequest.deny = function() { AppViewGuestInternal.denyRequest(id); }; dispatch([appEmbeddingRequest]); }); } registerArgumentMassager('app.runtime.onLaunched', function(args, dispatch) { var launchData = args[0]; if (launchData.items) { // An onLaunched corresponding to file_handlers in the app's manifest. var items = []; var numItems = launchData.items.length; var itemLoaded = function(err, item) { if (err) { console.error('Error getting fileEntry, code: ' + err.code); } else { $Array.push(items, item); } if (--numItems === 0) { var data = { isKioskSession: launchData.isKioskSession, isPublicSession: launchData.isPublicSession, source: launchData.source, actionData: launchData.actionData }; if (items.length !== 0) { data.id = launchData.id; data.items = items; } dispatch([data]); } }; $Array.forEach(launchData.items, function(item) { var fs = GetIsolatedFileSystem(item.fileSystemId); if (item.isDirectory) { fs.root.getDirectory(item.baseName, {}, function(dirEntry) { entryIdManager.registerEntry(item.entryId, dirEntry); itemLoaded(null, {entry: dirEntry}); }, function(fileError) { itemLoaded(fileError); }); } else { fs.root.getFile(item.baseName, {}, function(fileEntry) { entryIdManager.registerEntry(item.entryId, fileEntry); itemLoaded(null, {entry: fileEntry, type: item.mimeType}); }, function(fileError) { itemLoaded(fileError); }); } }); } else { // Default case. This currently covers an onLaunched corresponding to // url_handlers in the app's manifest. dispatch([launchData]); } }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // Custom binding for the app_window API. var appWindowNatives = requireNative('app_window_natives'); var runtimeNatives = requireNative('runtime'); var forEach = require('utils').forEach; var renderFrameObserverNatives = requireNative('renderFrameObserverNatives'); var appWindowData = null; var currentAppWindow = null; var currentWindowInternal = null; var kSetBoundsFunction = 'setBounds'; var kSetSizeConstraintsFunction = 'setSizeConstraints'; if (!apiBridge) var binding = require('binding').Binding; var jsEvent; function createAnonymousEvent() { if (bindingUtil) { var supportsFilters = false; var supportsLazyListeners = false; // Native custom events ignore schema. return bindingUtil.createCustomEvent(undefined, undefined, supportsFilters, supportsLazyListeners); } if (!jsEvent) jsEvent = require('event_bindings').Event; return new jsEvent(); } // Bounds class definition. var Bounds = function(boundsKey) { privates(this).boundsKey_ = boundsKey; }; Object.defineProperty(Bounds.prototype, 'left', { get: function() { return appWindowData[privates(this).boundsKey_].left; }, set: function(left) { this.setPosition(left, null); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'top', { get: function() { return appWindowData[privates(this).boundsKey_].top; }, set: function(top) { this.setPosition(null, top); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'width', { get: function() { return appWindowData[privates(this).boundsKey_].width; }, set: function(width) { this.setSize(width, null); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'height', { get: function() { return appWindowData[privates(this).boundsKey_].height; }, set: function(height) { this.setSize(null, height); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'minWidth', { get: function() { return appWindowData[privates(this).boundsKey_].minWidth; }, set: function(minWidth) { updateSizeConstraints(privates(this).boundsKey_, { minWidth: minWidth }); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'maxWidth', { get: function() { return appWindowData[privates(this).boundsKey_].maxWidth; }, set: function(maxWidth) { updateSizeConstraints(privates(this).boundsKey_, { maxWidth: maxWidth }); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'minHeight', { get: function() { return appWindowData[privates(this).boundsKey_].minHeight; }, set: function(minHeight) { updateSizeConstraints(privates(this).boundsKey_, { minHeight: minHeight }); }, enumerable: true }); Object.defineProperty(Bounds.prototype, 'maxHeight', { get: function() { return appWindowData[privates(this).boundsKey_].maxHeight; }, set: function(maxHeight) { updateSizeConstraints(privates(this).boundsKey_, { maxHeight: maxHeight }); }, enumerable: true }); Bounds.prototype.setPosition = function(left, top) { updateBounds(privates(this).boundsKey_, { left: left, top: top }); }; Bounds.prototype.setSize = function(width, height) { updateBounds(privates(this).boundsKey_, { width: width, height: height }); }; Bounds.prototype.setMinimumSize = function(minWidth, minHeight) { updateSizeConstraints(privates(this).boundsKey_, { minWidth: minWidth, minHeight: minHeight }); }; Bounds.prototype.setMaximumSize = function(maxWidth, maxHeight) { updateSizeConstraints(privates(this).boundsKey_, { maxWidth: maxWidth, maxHeight: maxHeight }); }; var appWindow = apiBridge || binding.create('app.window'); appWindow.registerCustomHook(function(bindingsAPI) { var apiFunctions = bindingsAPI.apiFunctions; apiFunctions.setCustomCallback('create', function(name, request, callback, windowParams) { var view = null; // When window creation fails, |windowParams| will be undefined. if (windowParams && windowParams.frameId) { view = appWindowNatives.GetFrame( windowParams.frameId, true /* notifyBrowser */); } if (!view) { // No route to created window. If given a callback, trigger it with an // undefined object. if (callback) callback(); return; } if (windowParams.existingWindow) { // Not creating a new window, but activating an existing one, so trigger // callback with existing window and don't do anything else. if (callback) callback(view.chrome.app.window.current()); return; } // Initialize appWindowData in the newly created JS context if (view.chrome.app) { view.chrome.app.window.initializeAppWindow(windowParams); } else { var sandbox_window_message = 'Creating sandboxed window, it doesn\'t ' + 'have access to the chrome.app API.'; if (callback) { sandbox_window_message = sandbox_window_message + ' The chrome.app.window.create callback will be called, but ' + 'there will be no object provided for the sandboxed window.'; } console.warn(sandbox_window_message); } if (callback) { if (!view || !view.chrome.app /* sandboxed window */) { callback(undefined); return; } var willCallback = renderFrameObserverNatives.OnDocumentElementCreated( windowParams.frameId, function(success) { if (success) { callback(view.chrome.app.window.current()); } else { callback(undefined); } }); if (!willCallback) { callback(undefined); } } }); apiFunctions.setHandleRequest('current', function() { if (!currentAppWindow) { console.error('The JavaScript context calling ' + 'chrome.app.window.current() has no associated AppWindow.'); return null; } return currentAppWindow; }); apiFunctions.setHandleRequest('getAll', function() { var views = runtimeNatives.GetExtensionViews(-1, -1, 'APP_WINDOW'); return $Array.map(views, function(win) { return win.chrome.app.window.current(); }); }); apiFunctions.setHandleRequest('get', function(id) { var windows = $Array.filter(chrome.app.window.getAll(), function(win) { return win.id == id; }); return windows.length > 0 ? windows[0] : null; }); apiFunctions.setHandleRequest('canSetVisibleOnAllWorkspaces', function() { return /Mac/.test(navigator.platform) || /Linux/.test(navigator.userAgent); }); // This is an internal function, but needs to be bound into a closure // so the correct JS context is used for global variables such as // currentWindowInternal, appWindowData, etc. apiFunctions.setHandleRequest('initializeAppWindow', function(params) { currentWindowInternal = getInternalApi ? getInternalApi('app.currentWindowInternal') : binding.create('app.currentWindowInternal').generate(); var AppWindow = function() { this.innerBounds = new Bounds('innerBounds'); this.outerBounds = new Bounds('outerBounds'); }; forEach(currentWindowInternal, function(key, value) { // Do not add internal functions that should not appear in the AppWindow // interface. They are called by Bounds mutators. if (key !== kSetBoundsFunction && key !== kSetSizeConstraintsFunction) AppWindow.prototype[key] = value; }); AppWindow.prototype.moveTo = $Function.bind(window.moveTo, window); AppWindow.prototype.resizeTo = $Function.bind(window.resizeTo, window); AppWindow.prototype.contentWindow = window; AppWindow.prototype.onClosed = createAnonymousEvent(); AppWindow.prototype.close = function() { this.contentWindow.close(); }; AppWindow.prototype.getBounds = function() { // This is to maintain backcompatibility with a bug on Windows and // ChromeOS, which returns the position of the window but the size of // the content. var innerBounds = appWindowData.innerBounds; var outerBounds = appWindowData.outerBounds; return { left: outerBounds.left, top: outerBounds.top, width: innerBounds.width, height: innerBounds.height }; }; AppWindow.prototype.setBounds = function(bounds) { updateBounds('bounds', bounds); }; AppWindow.prototype.isFullscreen = function() { return appWindowData.fullscreen; }; AppWindow.prototype.isMinimized = function() { return appWindowData.minimized; }; AppWindow.prototype.isMaximized = function() { return appWindowData.maximized; }; AppWindow.prototype.isAlwaysOnTop = function() { return appWindowData.alwaysOnTop; }; AppWindow.prototype.alphaEnabled = function() { return appWindowData.alphaEnabled; }; Object.defineProperty(AppWindow.prototype, 'id', {get: function() { return appWindowData.id; }}); // These properties are for testing. Object.defineProperty( AppWindow.prototype, 'hasFrameColor', {get: function() { return appWindowData.hasFrameColor; }}); Object.defineProperty(AppWindow.prototype, 'activeFrameColor', {get: function() { return appWindowData.activeFrameColor; }}); Object.defineProperty(AppWindow.prototype, 'inactiveFrameColor', {get: function() { return appWindowData.inactiveFrameColor; }}); appWindowData = { id: params.id || '', innerBounds: { left: params.innerBounds.left, top: params.innerBounds.top, width: params.innerBounds.width, height: params.innerBounds.height, minWidth: params.innerBounds.minWidth, minHeight: params.innerBounds.minHeight, maxWidth: params.innerBounds.maxWidth, maxHeight: params.innerBounds.maxHeight }, outerBounds: { left: params.outerBounds.left, top: params.outerBounds.top, width: params.outerBounds.width, height: params.outerBounds.height, minWidth: params.outerBounds.minWidth, minHeight: params.outerBounds.minHeight, maxWidth: params.outerBounds.maxWidth, maxHeight: params.outerBounds.maxHeight }, fullscreen: params.fullscreen, minimized: params.minimized, maximized: params.maximized, alwaysOnTop: params.alwaysOnTop, hasFrameColor: params.hasFrameColor, activeFrameColor: params.activeFrameColor, inactiveFrameColor: params.inactiveFrameColor, alphaEnabled: params.alphaEnabled }; currentAppWindow = new AppWindow; }); }); function boundsEqual(bounds1, bounds2) { if (!bounds1 || !bounds2) return false; return (bounds1.left == bounds2.left && bounds1.top == bounds2.top && bounds1.width == bounds2.width && bounds1.height == bounds2.height); } function dispatchEventIfExists(target, name) { // Sometimes apps like to put their own properties on the window which // break our assumptions. var event = target[name]; if (event && (typeof event.dispatch == 'function')) event.dispatch(); else console.warn('Could not dispatch ' + name + ', event has been clobbered'); } function updateAppWindowProperties(update) { if (!appWindowData) return; var oldData = appWindowData; update.id = oldData.id; appWindowData = update; var currentWindow = currentAppWindow; if (!boundsEqual(oldData.innerBounds, update.innerBounds)) dispatchEventIfExists(currentWindow, "onBoundsChanged"); if (!oldData.fullscreen && update.fullscreen) dispatchEventIfExists(currentWindow, "onFullscreened"); if (!oldData.minimized && update.minimized) dispatchEventIfExists(currentWindow, "onMinimized"); if (!oldData.maximized && update.maximized) dispatchEventIfExists(currentWindow, "onMaximized"); if ((oldData.fullscreen && !update.fullscreen) || (oldData.minimized && !update.minimized) || (oldData.maximized && !update.maximized)) dispatchEventIfExists(currentWindow, "onRestored"); if (oldData.alphaEnabled !== update.alphaEnabled) dispatchEventIfExists(currentWindow, "onAlphaEnabledChanged"); }; function onAppWindowClosed() { if (!currentAppWindow) return; dispatchEventIfExists(currentAppWindow, "onClosed"); } function updateBounds(boundsType, bounds) { if (!currentWindowInternal) return; currentWindowInternal.setBounds(boundsType, bounds); } function updateSizeConstraints(boundsType, constraints) { if (!currentWindowInternal) return; forEach(constraints, function(key, value) { // From the perspective of the API, null is used to reset constraints. // We need to convert this to 0 because a value of null is interpreted // the same as undefined in the browser and leaves the constraint unchanged. if (value === null) constraints[key] = 0; }); currentWindowInternal.setSizeConstraints(boundsType, constraints); } if (!apiBridge) exports.$set('binding', appWindow.generate()); exports.$set('onAppWindowClosed', onAppWindowClosed); exports.$set('updateAppWindowProperties', updateAppWindowProperties); // 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 Event = require('event_bindings').Event; var forEach = require('utils').forEach; // Note: Beware sneaky getters/setters when using GetAvailbility(). Use safe/raw // variables as arguments. var GetAvailability = requireNative('v8_context').GetAvailability; var exceptionHandler = require('uncaught_exception_handler'); var lastError = require('lastError'); var loadTypeSchema = require('json_schema').loadTypeSchema; var logActivity = requireNative('activityLogger'); var logging = requireNative('logging'); var process = requireNative('process'); var schemaRegistry = requireNative('schema_registry'); var schemaUtils = require('schemaUtils'); var sendRequestHandler = require('sendRequest'); var contextType = process.GetContextType(); var extensionId = process.GetExtensionId(); var manifestVersion = process.GetManifestVersion(); var platform = process.GetPlatform(); var sendRequest = sendRequestHandler.sendRequest; // Stores the name and definition of each API function, with methods to // modify their behaviour (such as a custom way to handle requests to the // API, a custom callback, etc). function APIFunctions(namespace) { this.apiFunctions_ = { __proto__: null }; this.unavailableApiFunctions_ = { __proto__: null }; this.namespace = namespace; } APIFunctions.prototype = { __proto__: null, }; APIFunctions.prototype.register = function(apiName, apiFunction) { this.apiFunctions_[apiName] = apiFunction; }; // Registers a function as existing but not available, meaning that calls to // the set* methods that reference this function should be ignored rather // than throwing Errors. APIFunctions.prototype.registerUnavailable = function(apiName) { this.unavailableApiFunctions_[apiName] = apiName; }; APIFunctions.prototype.setHook_ = function(apiName, propertyName, customizedFunction) { if ($Object.hasOwnProperty(this.unavailableApiFunctions_, apiName)) return; if (!$Object.hasOwnProperty(this.apiFunctions_, apiName)) throw new Error('Tried to set hook for unknown API "' + apiName + '"'); this.apiFunctions_[apiName][propertyName] = customizedFunction; }; APIFunctions.prototype.setHandleRequest = function(apiName, customizedFunction) { var prefix = this.namespace; return this.setHook_(apiName, 'handleRequest', function() { var ret = $Function.apply(customizedFunction, this, arguments); // Logs API calls to the Activity Log if it doesn't go through an // ExtensionFunction. if (!sendRequestHandler.getCalledSendRequest()) logActivity.LogAPICall(extensionId, prefix + "." + apiName, $Array.slice(arguments)); return ret; }); }; APIFunctions.prototype.setUpdateArgumentsPostValidate = function(apiName, customizedFunction) { return this.setHook_( apiName, 'updateArgumentsPostValidate', customizedFunction); }; APIFunctions.prototype.setUpdateArgumentsPreValidate = function(apiName, customizedFunction) { return this.setHook_( apiName, 'updateArgumentsPreValidate', customizedFunction); }; APIFunctions.prototype.setCustomCallback = function(apiName, customizedFunction) { return this.setHook_(apiName, 'customCallback', customizedFunction); }; function isPlatformSupported(schemaNode, platform) { return !schemaNode.platforms || $Array.indexOf(schemaNode.platforms, platform) > -1; } function isManifestVersionSupported(schemaNode, manifestVersion) { return !schemaNode.maximumManifestVersion || manifestVersion <= schemaNode.maximumManifestVersion; } function isSchemaNodeSupported(schemaNode, platform, manifestVersion) { return isPlatformSupported(schemaNode, platform) && isManifestVersionSupported(schemaNode, manifestVersion); } function createCustomType(type) { var jsModuleName = type.js_module; logging.CHECK(jsModuleName, 'Custom type ' + type.id + ' has no "js_module" property.'); // This list contains all types that has a js_module property. It is ugly to // hard-code them here, but the number of APIs that use js_module has not // changed since the introduction of js_modules in crbug.com/222156. // This whitelist serves as an extra line of defence to avoid exposing // arbitrary extension modules when the |type| definition is poisoned. var whitelistedModules = [ 'ChromeSetting', 'ContentSetting', 'EasyUnlockProximityRequired', 'StorageArea', ]; logging.CHECK($Array.indexOf(whitelistedModules, jsModuleName) !== -1, 'Module ' + jsModuleName + ' does not define a custom type.'); var jsModule = require(jsModuleName); logging.CHECK(jsModule, 'No module ' + jsModuleName + ' found for ' + type.id + '.'); var customType = jsModule[jsModuleName]; logging.CHECK(customType, jsModuleName + ' must export itself.'); return customType; } function Binding(apiName) { this.apiName_ = apiName; this.apiFunctions_ = new APIFunctions(apiName); this.customHooks_ = []; }; $Object.defineProperty(Binding, 'create', { __proto__: null, configurable: false, enumerable: false, value: function(apiName) { return new Binding(apiName); }, writable: false, }); Binding.prototype = { // Sneaky workaround for Object.prototype getters/setters - our prototype // isn't Object.prototype. SafeBuiltins (e.g. $Object.hasOwnProperty()) // should still work. __proto__: null, // Forward-declare properties. apiName_: undefined, apiFunctions_: undefined, customEvent_: undefined, customHooks_: undefined, // The API through which the ${api_name}_custom_bindings.js files customize // their API bindings beyond what can be generated. // // There are 2 types of customizations available: those which are required in // order to do the schema generation (registerCustomEvent and // registerCustomType), and those which can only run after the bindings have // been generated (registerCustomHook). // Registers a custom event type for the API identified by |namespace|. // |event| is the event's constructor. registerCustomEvent: function(event) { this.customEvent_ = event; }, // Registers a function |hook| to run after the schema for all APIs has been // generated. The hook is passed as its first argument an "API" object to // interact with, and second the current extension ID. See where // |customHooks| is used. registerCustomHook: function(fn) { $Array.push(this.customHooks_, fn); }, // TODO(kalman/cduvall): Refactor this so |runHooks_| is not needed. runHooks_: function(api, schema) { $Array.forEach(this.customHooks_, function(hook) { if (!isSchemaNodeSupported(schema, platform, manifestVersion)) return; if (!hook) return; hook({ __proto__: null, apiFunctions: this.apiFunctions_, schema: schema, compiledApi: api }, extensionId, contextType); }, this); }, // Generates the bindings from the schema for |this.apiName_| and integrates // any custom bindings that might be present. generate: function() { // NB: It's important to load the schema during generation rather than // setting it beforehand so that we're more confident the schema we're // loading is real, and not one that was injected by a page intercepting // Binding.generate. // Additionally, since the schema is an object returned from a native // handler, its properties don't have the custom getters/setters that a page // may have put on Object.prototype, and the object is frozen by v8. var schema = schemaRegistry.GetSchema(this.apiName_); function shouldCheckUnprivileged() { var shouldCheck = 'unprivileged' in schema; if (shouldCheck) return shouldCheck; $Array.forEach(['functions', 'events'], function(type) { if ($Object.hasOwnProperty(schema, type)) { $Array.forEach(schema[type], function(node) { if ('unprivileged' in node) shouldCheck = true; }); } }); if (shouldCheck) return shouldCheck; for (var property in schema.properties) { if ($Object.hasOwnProperty(schema, property) && 'unprivileged' in schema.properties[property]) { shouldCheck = true; break; } } return shouldCheck; } var checkUnprivileged = shouldCheckUnprivileged(); // TODO(kalman/cduvall): Make GetAvailability handle this, then delete the // supporting code. if (!isSchemaNodeSupported(schema, platform, manifestVersion)) { console.error('chrome.' + schema.namespace + ' is not supported on ' + 'this platform or manifest version'); return undefined; } var mod = {}; var namespaces = $String.split(schema.namespace, '.'); for (var index = 0, name; name = namespaces[index]; index++) { mod[name] = mod[name] || {}; mod = mod[name]; } if (schema.types) { $Array.forEach(schema.types, function(t) { if (!isSchemaNodeSupported(t, platform, manifestVersion)) return; // Add types to global schemaValidator; the types we depend on from // other namespaces will be added as needed. schemaUtils.schemaValidator.addTypes(t); // Generate symbols for enums. var enumValues = t['enum']; if (enumValues) { // Type IDs are qualified with the namespace during compilation, // unfortunately, so remove it here. logging.DCHECK($String.substr(t.id, 0, schema.namespace.length) == schema.namespace); // Note: + 1 because it ends in a '.', e.g., 'fooApi.Type'. var id = $String.substr(t.id, schema.namespace.length + 1); mod[id] = {}; $Array.forEach(enumValues, function(enumValue) { // Note: enums can be declared either as a list of strings // ['foo', 'bar'] or as a list of objects // [{'name': 'foo'}, {'name': 'bar'}]. enumValue = $Object.hasOwnProperty(enumValue, 'name') ? enumValue.name : enumValue; if (enumValue) { // Avoid setting any empty enums. // Make all properties in ALL_CAPS_STYLE. // // The built-in versions of $String.replace call other built-ins, // which may be clobbered. Instead, manually build the property // name. // // If the first character is a digit (we know it must be one of // a digit, a letter, or an underscore), precede it with an // underscore. var propertyName = ($RegExp.exec(/\d/, enumValue[0])) ? '_' : ''; for (var i = 0; i < enumValue.length; ++i) { var next; if (i > 0 && $RegExp.exec(/[a-z]/, enumValue[i-1]) && $RegExp.exec(/[A-Z]/, enumValue[i])) { // Replace myEnum-Foo with my_Enum-Foo: next = '_' + enumValue[i]; } else if ($RegExp.exec(/\W/, enumValue[i])) { // Replace my_Enum-Foo with my_Enum_Foo: next = '_'; } else { next = enumValue[i]; } propertyName += next; } // Uppercase (replace my_Enum_Foo with MY_ENUM_FOO): propertyName = $String.toUpperCase(propertyName); mod[id][propertyName] = enumValue; } }); } }, this); } // TODO(cduvall): Take out when all APIs have been converted to features. // Returns whether access to the content of a schema should be denied, // based on the presence of "unprivileged" and whether this is an // extension process (versus e.g. a content script). function isSchemaAccessAllowed(itemSchema) { return (contextType == 'BLESSED_EXTENSION') || schema.unprivileged || itemSchema.unprivileged; }; // Setup Functions. if (schema.functions) { $Array.forEach(schema.functions, function(functionDef) { if (functionDef.name in mod) { throw new Error('Function ' + functionDef.name + ' already defined in ' + schema.namespace); } if (!isSchemaNodeSupported(functionDef, platform, manifestVersion)) { this.apiFunctions_.registerUnavailable(functionDef.name); return; } var apiFunction = { __proto__: null }; apiFunction.definition = functionDef; apiFunction.name = schema.namespace + '.' + functionDef.name; if (!GetAvailability(apiFunction.name).is_available || (checkUnprivileged && !isSchemaAccessAllowed(functionDef))) { this.apiFunctions_.registerUnavailable(functionDef.name); return; } // TODO(aa): It would be best to run this in a unit test, but in order // to do that we would need to better factor this code so that it // doesn't depend on so much v8::Extension machinery. if (logging.DCHECK_IS_ON() && schemaUtils.isFunctionSignatureAmbiguous(apiFunction.definition)) { throw new Error( apiFunction.name + ' has ambiguous optional arguments. ' + 'To implement custom disambiguation logic, add ' + '"allowAmbiguousOptionalArguments" to the function\'s schema.'); } this.apiFunctions_.register(functionDef.name, apiFunction); mod[functionDef.name] = $Function.bind(function() { var args = $Array.slice(arguments); $Object.setPrototypeOf(args, null); if (this.updateArgumentsPreValidate) args = $Function.apply(this.updateArgumentsPreValidate, this, args); args = schemaUtils.normalizeArgumentsAndValidate(args, this); if (this.updateArgumentsPostValidate) { args = $Function.apply(this.updateArgumentsPostValidate, this, args); } sendRequestHandler.clearCalledSendRequest(); var retval; if (this.handleRequest) { retval = $Function.apply(this.handleRequest, this, args); } else { var optArgs = { __proto__: null, forIOThread: functionDef.forIOThread, customCallback: this.customCallback }; retval = sendRequest(this.name, args, this.definition.parameters, optArgs); } sendRequestHandler.clearCalledSendRequest(); // Validate return value if in sanity check mode. if (logging.DCHECK_IS_ON() && this.definition.returns) schemaUtils.validate([retval], [this.definition.returns]); return retval; }, apiFunction); }, this); } // Setup Events if (schema.events) { $Array.forEach(schema.events, function(eventDef) { if (eventDef.name in mod) { throw new Error('Event ' + eventDef.name + ' already defined in ' + schema.namespace); } if (!isSchemaNodeSupported(eventDef, platform, manifestVersion)) return; var eventName = schema.namespace + "." + eventDef.name; if (!GetAvailability(eventName).is_available || (checkUnprivileged && !isSchemaAccessAllowed(eventDef))) { return; } var options = eventDef.options || {}; if (eventDef.filters && eventDef.filters.length > 0) options.supportsFilters = true; var parameters = eventDef.parameters; if (this.customEvent_) { mod[eventDef.name] = new this.customEvent_( eventName, parameters, eventDef.extraParameters, options); } else { mod[eventDef.name] = new Event(eventName, parameters, options); } }, this); } function addProperties(m, parentDef) { var properties = parentDef.properties; if (!properties) return; forEach(properties, function(propertyName, propertyDef) { if (propertyName in m) return; // TODO(kalman): be strict like functions/events somehow. if (!isSchemaNodeSupported(propertyDef, platform, manifestVersion)) return; if (!GetAvailability(schema.namespace + "." + propertyName).is_available || (checkUnprivileged && !isSchemaAccessAllowed(propertyDef))) { return; } // |value| is eventually added to |m|, the exposed API. Make copies // of everything from the schema. (The schema is also frozen, so as long // as we don't make any modifications, shallow copies are fine.) var value; if ($Array.isArray(propertyDef.value)) value = $Array.slice(propertyDef.value); else if (typeof propertyDef.value === 'object') value = $Object.assign({}, propertyDef.value); else value = propertyDef.value; if (value) { // Values may just have raw types as defined in the JSON, such // as "WINDOW_ID_NONE": { "value": -1 }. We handle this here. // TODO(kalman): enforce that things with a "value" property can't // define their own types. var type = propertyDef.type || typeof(value); if (type === 'integer' || type === 'number') { value = parseInt(value); } else if (type === 'boolean') { value = value === 'true'; } else if (propertyDef['$ref']) { var ref = propertyDef['$ref']; var type = loadTypeSchema(propertyDef['$ref'], schema); logging.CHECK(type, 'Schema for $ref type ' + ref + ' not found'); var constructor = createCustomType(type); var args = value; logging.DCHECK($Array.isArray(args)); $Array.push(args, type); // For an object propertyDef, |value| is an array of constructor // arguments, but we want to pass the arguments directly (i.e. // not as an array), so we have to fake calling |new| on the // constructor. value = { __proto__: constructor.prototype }; $Function.apply(constructor, value, args); // Recursively add properties. addProperties(value, propertyDef); } else if (type === 'object') { // Recursively add properties. addProperties(value, propertyDef); } else if (type !== 'string') { throw new Error('NOT IMPLEMENTED (extension_api.json error): ' + 'Cannot parse values for type "' + type + '"'); } m[propertyName] = value; } }); }; addProperties(mod, schema); // This generate() call is considered successful if any functions, // properties, or events were created. var success = ($Object.keys(mod).length > 0); // Special case: webViewRequest is a vacuous API which just copies its // implementation from declarativeWebRequest. // // TODO(kalman): This would be unnecessary if we did these checks after the // hooks (i.e. this.runHooks_(mod)). The reason we don't is to be very // conservative with running any JS which might actually be for an API // which isn't available, but this is probably overly cautious given the // C++ is only giving us APIs which are available. FIXME. if (schema.namespace == 'webViewRequest') { success = true; } // Special case: runtime.lastError is only occasionally set, so // specifically check its availability. if (schema.namespace == 'runtime' && GetAvailability('runtime.lastError').is_available) { success = true; } if (!success) { var availability = GetAvailability(schema.namespace); // If an API was available it should have been successfully generated. logging.DCHECK(!availability.is_available, schema.namespace + ' was available but not generated'); console.error('chrome.' + schema.namespace + ' is not available: ' + availability.message); return; } this.runHooks_(mod, schema); return mod; } }; exports.$set('Binding', Binding); // 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. // Custom binding for the contextMenus API. var binding = apiBridge || require('binding').Binding.create('contextMenus'); var contextMenusHandlers = require('contextMenusHandlers'); binding.registerCustomHook(function(bindingsAPI) { var apiFunctions = bindingsAPI.apiFunctions; var handlers = contextMenusHandlers.create(false /* isWebview */); apiFunctions.setHandleRequest('create', handlers.requestHandlers.create); apiFunctions.setHandleRequest('remove', handlers.requestHandlers.remove); apiFunctions.setHandleRequest('update', handlers.requestHandlers.update); apiFunctions.setHandleRequest('removeAll', handlers.requestHandlers.removeAll); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // Implementation of custom bindings for the contextMenus API. // This is used to implement the contextMenus API for extensions and for the // tag (see chrome_web_view_experimental.js). var contextMenuNatives = requireNative('context_menus'); var sendRequest = bindingUtil ? $Function.bind(bindingUtil.sendRequest, bindingUtil) : require('sendRequest').sendRequest; var hasLastError = bindingUtil ? $Function.bind(bindingUtil.hasLastError, bindingUtil) : require('lastError').hasError; var jsEvent; function createNewEvent(name, isWebview) { var supportsLazyListeners = !isWebview; var supportsFilters = false; if (bindingUtil) { // Native custom events ignore schema. return bindingUtil.createCustomEvent(name, undefined, supportsFilters, supportsLazyListeners); } if (!jsEvent) jsEvent = require('event_bindings').Event; var eventOpts = { __proto__: null, supportsLazyListeners: supportsLazyListeners, supportsFilters: supportsFilters, }; return new jsEvent(name, null, eventOpts); } // Add the bindings to the contextMenus API. function createContextMenusHandlers(isWebview) { var eventName = isWebview ? 'webViewInternal.contextMenus' : 'contextMenus'; // Some dummy value for chrome.contextMenus instances. // Webviews use positive integers, and 0 to denote an invalid webview ID. // The following constant is -1 to avoid any conflicts between webview IDs and // extensions. var INSTANCEID_NON_WEBVIEW = -1; // Generates a customCallback for a given method. |handleCallback| will be // invoked with the same arguments this function is called with. function getCallback(handleCallback) { return function() { var extensionCallback = arguments[arguments.length - 1]; if (hasLastError(bindingUtil ? undefined : chrome)) { if (extensionCallback) extensionCallback(); return; } $Function.apply(handleCallback, null, arguments); if (extensionCallback) extensionCallback(); }; } var contextMenus = { __proto__: null }; contextMenus.handlers = { __proto__: null }; contextMenus.event = createNewEvent(eventName, isWebview); contextMenus.getIdFromCreateProperties = function(createProperties) { if (typeof createProperties.id !== 'undefined') return createProperties.id; return createProperties.generatedId; }; contextMenus.handlersForId = function(instanceId, id) { if (!contextMenus.handlers[instanceId]) { contextMenus.handlers[instanceId] = { generated: {}, string: {} }; } if (typeof id === 'number') return contextMenus.handlers[instanceId].generated; return contextMenus.handlers[instanceId].string; }; contextMenus.ensureListenerSetup = function() { if (contextMenus.listening) { return; } contextMenus.listening = true; contextMenus.event.addListener(function(info) { var instanceId = INSTANCEID_NON_WEBVIEW; if (isWebview) { instanceId = info.webviewInstanceId; // Don't expose |webviewInstanceId| via the public API. delete info.webviewInstanceId; } var id = info.menuItemId; var onclick = contextMenus.handlersForId(instanceId, id)[id]; if (onclick) { $Function.apply(onclick, null, arguments); } }); }; // To be used with apiFunctions.setHandleRequest var requestHandlers = { __proto__: null }; function createCallback(instanceId, id, onclick) { if (onclick) { contextMenus.ensureListenerSetup(); contextMenus.handlersForId(instanceId, id)[id] = onclick; } } requestHandlers.create = function() { var createProperties = isWebview ? arguments[1] : arguments[0]; createProperties.generatedId = contextMenuNatives.GetNextContextMenuId(); var id = contextMenus.getIdFromCreateProperties(createProperties); var instanceId = isWebview ? arguments[0] : INSTANCEID_NON_WEBVIEW; var onclick = createProperties.onclick; var optArgs = { __proto__: null, customCallback: getCallback($Function.bind(createCallback, null, instanceId, id, onclick)), }; var name = isWebview ? 'chromeWebViewInternal.contextMenusCreate' : 'contextMenus.create'; sendRequest(name, $Array.from(arguments), bindingUtil ? undefined : this.definition.parameters, optArgs); return id; }; function removeCallback(instanceId, id) { delete contextMenus.handlersForId(instanceId, id)[id]; } requestHandlers.remove = function() { var instanceId = isWebview ? arguments[0] : INSTANCEID_NON_WEBVIEW; var id = isWebview ? arguments[1] : arguments[0]; var optArgs = { __proto__: null, customCallback: getCallback($Function.bind(removeCallback, null, instanceId, id)), }; var name = isWebview ? 'chromeWebViewInternal.contextMenusRemove' : 'contextMenus.remove'; sendRequest(name, $Array.from(arguments), bindingUtil ? undefined : this.definition.parameters, optArgs); }; function updateCallback(instanceId, id, onclick) { if (onclick) { contextMenus.ensureListenerSetup(); contextMenus.handlersForId(instanceId, id)[id] = onclick; } else if (onclick === null) { // When onclick is explicitly set to null, remove the event listener. delete contextMenus.handlersForId(instanceId, id)[id]; } } requestHandlers.update = function() { var instanceId = isWebview ? arguments[0] : INSTANCEID_NON_WEBVIEW; var id = isWebview ? arguments[1] : arguments[0]; var updateProperties = isWebview ? arguments[2] : arguments[1]; var onclick = updateProperties.onclick; var optArgs = { __proto__: null, customCallback: getCallback($Function.bind(updateCallback, null, instanceId, id, onclick)), }; var name = isWebview ? 'chromeWebViewInternal.contextMenusUpdate' : 'contextMenus.update'; sendRequest(name, $Array.from(arguments), bindingUtil ? undefined : this.definition.parameters, optArgs); }; function removeAllCallback(instanceId) { delete contextMenus.handlers[instanceId]; } requestHandlers.removeAll = function() { var instanceId = isWebview ? arguments[0] : INSTANCEID_NON_WEBVIEW; var optArgs = { __proto__: null, customCallback: getCallback($Function.bind(removeAllCallback, null, instanceId)), }; var name = isWebview ? 'chromeWebViewInternal.contextMenusRemoveAll' : 'contextMenus.removeAll'; sendRequest(name, $Array.from(arguments), bindingUtil ? undefined : this.definition.parameters, optArgs); }; return { requestHandlers: requestHandlers, }; } exports.$set('create', createContextMenusHandlers); // 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. // Custom binding for the declarativeWebRequest API. var binding = apiBridge || require('binding').Binding.create('declarativeWebRequest'); var utils = require('utils'); var validate = require('schemaUtils').validate; binding.registerCustomHook(function(api) { var declarativeWebRequest = api.compiledApi; // Returns the schema definition of type |typeId| defined in |namespace|. function getSchema(typeId) { return utils.lookup(api.schema.types, 'id', 'declarativeWebRequest.' + typeId); } // Helper function for the constructor of concrete datatypes of the // declarative webRequest API. // Makes sure that |this| contains the union of parameters and // {'instanceType': 'declarativeWebRequest.' + typeId} and validates the // generated union dictionary against the schema for |typeId|. function setupInstance(instance, parameters, typeId) { for (var key in parameters) { if ($Object.hasOwnProperty(parameters, key)) { instance[key] = parameters[key]; } } instance.instanceType = 'declarativeWebRequest.' + typeId; if (!apiBridge) { var schema = getSchema(typeId); // TODO(devlin): This won't work with native bindings, but it's lower // priority. declarativeWebRequest never shipped, and validation will // fail later when trying to use the created object. Still, it'd be // potentially nice to fix. validate([instance], [schema]); } } // Setup all data types for the declarative webRequest API. declarativeWebRequest.RequestMatcher = function(parameters) { setupInstance(this, parameters, 'RequestMatcher'); }; declarativeWebRequest.CancelRequest = function(parameters) { setupInstance(this, parameters, 'CancelRequest'); }; declarativeWebRequest.RedirectRequest = function(parameters) { setupInstance(this, parameters, 'RedirectRequest'); }; declarativeWebRequest.SetRequestHeader = function(parameters) { setupInstance(this, parameters, 'SetRequestHeader'); }; declarativeWebRequest.RemoveRequestHeader = function(parameters) { setupInstance(this, parameters, 'RemoveRequestHeader'); }; declarativeWebRequest.AddResponseHeader = function(parameters) { setupInstance(this, parameters, 'AddResponseHeader'); }; declarativeWebRequest.RemoveResponseHeader = function(parameters) { setupInstance(this, parameters, 'RemoveResponseHeader'); }; declarativeWebRequest.RedirectToTransparentImage = function(parameters) { setupInstance(this, parameters, 'RedirectToTransparentImage'); }; declarativeWebRequest.RedirectToEmptyDocument = function(parameters) { setupInstance(this, parameters, 'RedirectToEmptyDocument'); }; declarativeWebRequest.RedirectByRegEx = function(parameters) { setupInstance(this, parameters, 'RedirectByRegEx'); }; declarativeWebRequest.IgnoreRules = function(parameters) { setupInstance(this, parameters, 'IgnoreRules'); }; declarativeWebRequest.AddRequestCookie = function(parameters) { setupInstance(this, parameters, 'AddRequestCookie'); }; declarativeWebRequest.AddResponseCookie = function(parameters) { setupInstance(this, parameters, 'AddResponseCookie'); }; declarativeWebRequest.EditRequestCookie = function(parameters) { setupInstance(this, parameters, 'EditRequestCookie'); }; declarativeWebRequest.EditResponseCookie = function(parameters) { setupInstance(this, parameters, 'EditResponseCookie'); }; declarativeWebRequest.RemoveRequestCookie = function(parameters) { setupInstance(this, parameters, 'RemoveRequestCookie'); }; declarativeWebRequest.RemoveResponseCookie = function(parameters) { setupInstance(this, parameters, 'RemoveResponseCookie'); }; declarativeWebRequest.SendMessageToExtension = function(parameters) { setupInstance(this, parameters, 'SendMessageToExtension'); }; }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // Custom binding for the Display Source API. var binding = apiBridge || require('binding').Binding.create('displaySource'); var chrome = requireNative('chrome').GetChrome(); var natives = requireNative('display_source'); var logging = requireNative('logging'); var jsLastError = bindingUtil ? undefined : require('lastError'); function setLastError(name, message) { if (bindingUtil) bindingUtil.setLastError(message); else jsLastError.set(name, message, null, chrome); } function clearLastError() { if (bindingUtil) bindingUtil.clearLastError(); else jsLastError.clear(chrome); } var callbacksInfo = {}; function callbackWrapper(callback, method, message) { if (callback == undefined) return; try { if (message !== null) setLastError(method, message); callback(); } finally { clearLastError(); } } function callCompletionCallback(callbackId, error_message) { try { var callbackInfo = callbacksInfo[callbackId]; logging.DCHECK(callbackInfo != null); callbackWrapper(callbackInfo.callback, callbackInfo.method, error_message); } finally { delete callbacksInfo[callbackId]; } } binding.registerCustomHook(function(bindingsAPI, extensionId) { var apiFunctions = bindingsAPI.apiFunctions; apiFunctions.setHandleRequest( 'startSession', function(sessionInfo, callback) { try { var callId = natives.StartSession(sessionInfo); callbacksInfo[callId] = { callback: callback, method: 'displaySource.startSession' }; } catch (e) { callbackWrapper(callback, 'displaySource.startSession', e.message); } }); apiFunctions.setHandleRequest( 'terminateSession', function(sink_id, callback) { try { var callId = natives.TerminateSession(sink_id); callbacksInfo[callId] = { callback: callback, method: 'displaySource.terminateSession' }; } catch (e) { callbackWrapper( callback, 'displaySource.terminateSession', e.message); } }); }); if (!apiBridge) exports.$set('binding', binding.generate()); // Called by C++. exports.$set('callCompletionCallback', callCompletionCallback); // 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. // Custom binding for the extension API. var binding = apiBridge || require('binding').Binding.create('extension'); var messaging = require('messaging'); var runtimeNatives = requireNative('runtime'); var GetExtensionViews = runtimeNatives.GetExtensionViews; var chrome = requireNative('chrome').GetChrome(); var inIncognitoContext = requireNative('process').InIncognitoContext(); var sendRequestIsDisabled = requireNative('process').IsSendRequestDisabled(); var contextType = requireNative('process').GetContextType(); var manifestVersion = requireNative('process').GetManifestVersion(); // This should match chrome.windows.WINDOW_ID_NONE. // // We can't use chrome.windows.WINDOW_ID_NONE directly because the // chrome.windows API won't exist unless this extension has permission for it; // which may not be the case. var WINDOW_ID_NONE = -1; var TAB_ID_NONE = -1; binding.registerCustomHook(function(bindingsAPI, extensionId) { var extension = bindingsAPI.compiledApi; if (manifestVersion < 2) { chrome.self = extension; extension.inIncognitoTab = inIncognitoContext; } extension.inIncognitoContext = inIncognitoContext; var apiFunctions = bindingsAPI.apiFunctions; apiFunctions.setHandleRequest('getViews', function(properties) { var windowId = WINDOW_ID_NONE; var tabId = TAB_ID_NONE; var type = 'ALL'; if (properties) { if (properties.type != null) { type = properties.type; } if (properties.windowId != null) { windowId = properties.windowId; } if (properties.tabId != null) { tabId = properties.tabId; } } return GetExtensionViews(windowId, tabId, type); }); apiFunctions.setHandleRequest('getBackgroundPage', function() { return GetExtensionViews(-1, -1, 'BACKGROUND')[0] || null; }); apiFunctions.setHandleRequest('getExtensionTabs', function(windowId) { if (windowId == null) windowId = WINDOW_ID_NONE; return GetExtensionViews(windowId, -1, 'TAB'); }); apiFunctions.setHandleRequest('getURL', function(path) { path = String(path); if (!path.length || path[0] != '/') path = '/' + path; return 'chrome-extension://' + extensionId + path; }); // Alias several messaging deprecated APIs to their runtime counterparts. var mayNeedAlias = [ // Types 'Port', // Functions 'connect', 'sendMessage', 'connectNative', 'sendNativeMessage', // Events 'onConnect', 'onConnectExternal', 'onMessage', 'onMessageExternal' ]; $Array.forEach(mayNeedAlias, function(alias) { // Checking existence isn't enough since some functions are disabled via // getters that throw exceptions. Assume that any getter is such a function. if (chrome.runtime && $Object.hasOwnProperty(chrome.runtime, alias) && chrome.runtime.__lookupGetter__(alias) === undefined) { extension[alias] = chrome.runtime[alias]; } }); apiFunctions.setUpdateArgumentsPreValidate('sendRequest', $Function.bind(messaging.sendMessageUpdateArguments, null, 'sendRequest', false /* hasOptionsArgument */)); apiFunctions.setHandleRequest('sendRequest', function(targetId, request, responseCallback) { if (sendRequestIsDisabled) throw new Error(sendRequestIsDisabled); var port = chrome.runtime.connect(targetId || extensionId, {name: messaging.kRequestChannel}); messaging.sendMessageImpl(port, request, responseCallback); }); if (sendRequestIsDisabled) { extension.onRequest.addListener = function() { throw new Error(sendRequestIsDisabled); }; if (contextType == 'BLESSED_EXTENSION') { extension.onRequestExternal.addListener = function() { throw new Error(sendRequestIsDisabled); }; } } }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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 fileSystemNatives = requireNative('file_system_natives'); var GetIsolatedFileSystem = fileSystemNatives.GetIsolatedFileSystem; var GetModuleSystem = requireNative('v8_context').GetModuleSystem; // TODO(sammc): Don't require extension. See http://crbug.com/235689. var GetExtensionViews = requireNative('runtime').GetExtensionViews; var safeCallbackApply = require('uncaught_exception_handler').safeCallbackApply; var jsLastError = bindingUtil ? undefined : require('lastError'); function runCallbackWithLastError(name, message, stack, callback) { if (bindingUtil) bindingUtil.runCallbackWithLastError(message, callback); else jsLastError.run(name, message, stack, callback); } var WINDOW = {}; try { WINDOW = window; } catch (e) { // Running in SW context. // TODO(lazyboy): Synchronous access to background page is not possible from // service worker context. Decide what we should do in this case for the class // of APIs that require access to background page or window object } // For a given |apiName|, generates object with two elements that are used // in file system relayed APIs: // * 'bindFileEntryCallback' function that provides mapping between JS objects // into actual FileEntry|DirectoryEntry objects. // * 'entryIdManager' object that implements methods for keeping the tracks of // previously saved file entries. function getFileBindingsForApi(apiName) { // Fallback to using the current window if no background page is running. var views = GetExtensionViews(-1, -1, 'BACKGROUND'); // GetExtensionViews() can return null if called from a context without an // associated extension. var backgroundPage = views && views[0] ? views[0] : WINDOW; var backgroundPageModuleSystem = GetModuleSystem(backgroundPage); // All windows use the bindFileEntryCallback from the background page so their // FileEntry objects have the background page's context as their own. This // allows them to be used from other windows (including the background page) // after the original window is closed. if (WINDOW == backgroundPage) { var bindFileEntryCallback = function(functionName, apiFunctions) { apiFunctions.setCustomCallback(functionName, function(name, request, callback, response) { if (callback) { if (!response) { callback(); return; } var entries = []; var hasError = false; var getEntryError = function(fileError) { if (!hasError) { hasError = true; runCallbackWithLastError( apiName + '.' + functionName, 'Error getting fileEntry, code: ' + fileError.code, request.stack, callback); } } // Loop through the response entries and asynchronously get the // FileEntry for each. We use hasError to ensure that only the first // error is reported. Note that an error can occur either during the // loop or in the asynchronous error callback to getFile. $Array.forEach(response.entries, function(entry) { if (hasError) return; var fileSystemId = entry.fileSystemId; var baseName = entry.baseName; var id = entry.id; var fs = GetIsolatedFileSystem(fileSystemId); try { var getEntryCallback = function(fileEntry) { if (hasError) return; entryIdManager.registerEntry(id, fileEntry); entries.push(fileEntry); // Once all entries are ready, pass them to the callback. In the // event of an error, this condition will never be satisfied so // the callback will not be called with any entries. if (entries.length == response.entries.length) { if (response.multiple) { safeCallbackApply(apiName + '.' + functionName, request, callback, [entries]); } else { safeCallbackApply( apiName + '.' + functionName, request, callback, [entries[0]]); } } } // TODO(koz): fs.root.getFile() makes a trip to the browser // process, but it might be possible avoid that by calling // WebDOMFileSystem::createV8Entry(). if (entry.isDirectory) { fs.root.getDirectory(baseName, {}, getEntryCallback, getEntryError); } else { fs.root.getFile(baseName, {}, getEntryCallback, getEntryError); } } catch (e) { if (!hasError) { hasError = true; runCallbackWithLastError(apiName + '.' + functionName, 'Error getting fileEntry: ' + e.stack, request.stack, callback); } } }); } }); }; var entryIdManager = require('entryIdManager'); } else { // Force the fileSystem API to be loaded in the background page. Using // backgroundPageModuleSystem.require('fileSystem') is insufficient as // requireNative is only allowed while lazily loading an API. backgroundPage.chrome.fileSystem; var bindFileEntryCallback = backgroundPageModuleSystem.require('fileEntryBindingUtil') .getFileBindingsForApi(apiName).bindFileEntryCallback; var entryIdManager = backgroundPageModuleSystem.require('entryIdManager'); } return {bindFileEntryCallback: bindFileEntryCallback, entryIdManager: entryIdManager}; } function getBindDirectoryEntryCallback() { // Get the background page if one exists. Otherwise, default to the current // window. var views = GetExtensionViews(-1, -1, 'BACKGROUND'); // GetExtensionViews() can return null if called from a context without an // associated extension. var backgroundPage = views && views[0] ? views[0] : WINDOW; // For packaged apps, all windows use the bindFileEntryCallback from the // background page so their FileEntry objects have the background page's // context as their own. This allows them to be used from other windows // (including the background page) after the original window is closed. if (WINDOW == backgroundPage) { return function(name, request, callback, response) { if (callback) { if (!response) { callback(); return; } var fileSystemId = response.fileSystemId; var baseName = response.baseName; var fs = GetIsolatedFileSystem(fileSystemId); try { fs.root.getDirectory(baseName, {}, callback, function(fileError) { runCallbackWithLastError( 'runtime.' + functionName, 'Error getting Entry, code: ' + fileError.code, request.stack, callback); }); } catch (e) { runCallbackWithLastError('runtime.' + functionName, 'Error: ' + e.stack, request.stack, callback); } } } } else { var backgroundPageModuleSystem = GetModuleSystem(backgroundPage); // Force the runtime API to be loaded in the background page. Using // backgroundPageModuleSystem.require('runtime') is insufficient as // requireNative is only allowed while lazily loading an API. backgroundPage.chrome.runtime; return backgroundPageModuleSystem.require('fileEntryBindingUtil') .getBindDirectoryEntryCallback(); } } exports.$set('getFileBindingsForApi', getFileBindingsForApi); exports.$set('getBindDirectoryEntryCallback', getBindDirectoryEntryCallback); // 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. // Custom binding for the fileSystem API. var binding = apiBridge || require('binding').Binding.create('fileSystem'); var sendRequest = bindingUtil ? $Function.bind(bindingUtil.sendRequest, bindingUtil) : require('sendRequest').sendRequest; var getFileBindingsForApi = require('fileEntryBindingUtil').getFileBindingsForApi; var fileBindings = getFileBindingsForApi('fileSystem'); var bindFileEntryCallback = fileBindings.bindFileEntryCallback; var entryIdManager = fileBindings.entryIdManager; var fileSystemNatives = requireNative('file_system_natives'); var safeCallbackApply = require('uncaught_exception_handler').safeCallbackApply; binding.registerCustomHook(function(bindingsAPI) { var apiFunctions = bindingsAPI.apiFunctions; var fileSystem = bindingsAPI.compiledApi; function bindFileEntryFunction(functionName) { apiFunctions.setUpdateArgumentsPostValidate( functionName, function(fileEntry, callback) { var fileSystemName = fileEntry.filesystem.name; var relativePath = $String.slice(fileEntry.fullPath, 1); return [fileSystemName, relativePath, callback]; }); } $Array.forEach(['getDisplayPath', 'getWritableEntry', 'isWritableEntry'], bindFileEntryFunction); $Array.forEach(['getWritableEntry', 'chooseEntry', 'restoreEntry'], function(functionName) { bindFileEntryCallback(functionName, apiFunctions); }); apiFunctions.setHandleRequest('retainEntry', function(fileEntry) { var id = entryIdManager.getEntryId(fileEntry); if (!id) return ''; var fileSystemName = fileEntry.filesystem.name; var relativePath = $String.slice(fileEntry.fullPath, 1); sendRequest('fileSystem.retainEntry', [id, fileSystemName, relativePath], bindingUtil ? undefined : this.definition.parameters, undefined); return id; }); apiFunctions.setHandleRequest('isRestorable', function(id, callback) { var savedEntry = entryIdManager.getEntryById(id); if (savedEntry) { safeCallbackApply('fileSystem.isRestorable', {}, callback, [true]); } else { sendRequest('fileSystem.isRestorable', [id, callback], bindingUtil ? undefined : this.definition.parameters, undefined); } }); apiFunctions.setUpdateArgumentsPostValidate('restoreEntry', function(id, callback) { var savedEntry = entryIdManager.getEntryById(id); if (savedEntry) { // We already have a file entry for this id so pass it to the callback and // send a request to the browser to move it to the back of the LRU. safeCallbackApply('fileSystem.restoreEntry', {}, callback, [savedEntry]); return [id, false, null]; } else { // Ask the browser process for a new file entry for this id, to be passed // to |callback|. return [id, true, callback]; } }); apiFunctions.setCustomCallback('requestFileSystem', function(name, request, callback, response) { var fileSystem; if (response && response.file_system_id) { fileSystem = fileSystemNatives.GetIsolatedFileSystem( response.file_system_id, response.file_system_path); } safeCallbackApply('fileSystem.requestFileSystem', request, callback, [fileSystem]); }); // TODO(benwells): Remove these deprecated versions of the functions. fileSystem.getWritableFileEntry = function() { console.log("chrome.fileSystem.getWritableFileEntry is deprecated"); console.log("Please use chrome.fileSystem.getWritableEntry instead"); $Function.apply(fileSystem.getWritableEntry, this, arguments); }; fileSystem.isWritableFileEntry = function() { console.log("chrome.fileSystem.isWritableFileEntry is deprecated"); console.log("Please use chrome.fileSystem.isWritableEntry instead"); $Function.apply(fileSystem.isWritableEntry, this, arguments); }; fileSystem.chooseFile = function() { console.log("chrome.fileSystem.chooseFile is deprecated"); console.log("Please use chrome.fileSystem.chooseEntry instead"); $Function.apply(fileSystem.chooseEntry, this, arguments); }; }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // ----------------------------------------------------------------------------- // NOTE: If you change this file you need to touch renderer_resources.grd to // have your change take effect. // ----------------------------------------------------------------------------- // Partial implementation of the Greasemonkey API, see: // http://wiki.greasespot.net/Greasemonkey_Manual:APIs function GM_addStyle(css) { var parent = document.getElementsByTagName("head")[0]; if (!parent) { parent = document.documentElement; } var style = document.createElement("style"); style.type = "text/css"; var textNode = document.createTextNode(css); style.appendChild(textNode); parent.appendChild(style); } function GM_xmlhttpRequest(details) { function setupEvent(xhr, url, eventName, callback) { xhr[eventName] = function () { var isComplete = xhr.readyState == 4; var responseState = { responseText: xhr.responseText, readyState: xhr.readyState, responseHeaders: isComplete ? xhr.getAllResponseHeaders() : "", status: isComplete ? xhr.status : 0, statusText: isComplete ? xhr.statusText : "", finalUrl: isComplete ? url : "" }; callback(responseState); }; } var xhr = new XMLHttpRequest(); var eventNames = ["onload", "onerror", "onreadystatechange"]; for (var i = 0; i < eventNames.length; i++ ) { var eventName = eventNames[i]; if (eventName in details) { setupEvent(xhr, details.url, eventName, details[eventName]); } } xhr.open(details.method, details.url); if (details.overrideMimeType) { xhr.overrideMimeType(details.overrideMimeType); } if (details.headers) { for (var header in details.headers) { xhr.setRequestHeader(header, details.headers[header]); } } xhr.send(details.data ? details.data : null); } function GM_openInTab(url) { window.open(url, ""); } function GM_log(message) { window.console.log(message); } (function() { function generateGreasemonkeyStub(name) { return function() { console.log("%s is not supported.", name); }; } var apis = ["GM_getValue", "GM_setValue", "GM_registerMenuCommand"]; for (var i = 0, api; api = apis[i]; i++) { window[api] = generateGreasemonkeyStub(api); } })(); // 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. // Custom binding for the i18n API. var binding = apiBridge || require('binding').Binding.create('i18n'); var i18nNatives = requireNative('i18n'); var GetL10nMessage = i18nNatives.GetL10nMessage; var GetL10nUILanguage = i18nNatives.GetL10nUILanguage; var DetectTextLanguage = i18nNatives.DetectTextLanguage; binding.registerCustomHook(function(bindingsAPI, extensionId) { var apiFunctions = bindingsAPI.apiFunctions; apiFunctions.setUpdateArgumentsPreValidate('getMessage', function() { var args = $Array.slice(arguments); // The first argument is the message, and should be a string. var message = args[0]; if (typeof(message) !== 'string') { console.warn(extensionId + ': the first argument to getMessage should ' + 'be type "string", was ' + message + ' (type "' + typeof(message) + '")'); args[0] = String(message); } return args; }); apiFunctions.setHandleRequest('getMessage', function(messageName, substitutions) { return GetL10nMessage(messageName, substitutions, extensionId); }); apiFunctions.setHandleRequest('getUILanguage', function() { return GetL10nUILanguage(); }); apiFunctions.setHandleRequest('detectLanguage', function(text, callback) { window.setTimeout(function() { var response = DetectTextLanguage(text); callback(response); }, 0); }); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. /** * Custom bindings for the mojoPrivate API. */ let binding = apiBridge || require('binding').Binding.create('mojoPrivate'); binding.registerCustomHook(function(bindingsAPI) { let apiFunctions = bindingsAPI.apiFunctions; apiFunctions.setHandleRequest('requireAsync', function(moduleName) { return Promise.resolve(require(moduleName).returnValue); }); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. // Custom binding for the Permissions API. var binding = apiBridge || require('binding').Binding.create('permissions'); var registerArgumentMassager = bindingUtil ? $Function.bind(bindingUtil.registerEventArgumentMassager, bindingUtil) : require('event_bindings').registerArgumentMassager; function maybeConvertToObject(str) { var parts = $String.split(str, '|'); if (parts.length != 2) return str; var ret = {}; ret[parts[0]] = $JSON.parse(parts[1]); return ret; } function massager(args, dispatch) { // Convert complex permissions back to objects for events. for (var i = 0; i < args[0].permissions.length; ++i) args[0].permissions[i] = maybeConvertToObject(args[0].permissions[i]); dispatch(args); } registerArgumentMassager('permissions.onAdded', massager); registerArgumentMassager('permissions.onRemoved', massager); // These custom binding are only necessary because it is not currently // possible to have a union of types as the type of the items in an array. // Once that is fixed, this entire file should go away. // See, // https://code.google.com/p/chromium/issues/detail?id=162044 // https://code.google.com/p/chromium/issues/detail?id=162042 // TODO(bryeung): delete this file. binding.registerCustomHook(function(api) { var apiFunctions = api.apiFunctions; var permissions = api.compiledApi; function convertObjectPermissionsToStrings() { if (arguments.length < 1) return arguments; var args = arguments[0].permissions; if (!args) return arguments; for (var i = 0; i < args.length; ++i) { if (typeof args[i] == 'object') { var a = args[i]; var keys = $Object.keys(a); if (keys.length != 1) { throw new Error('Too many keys in object-style permission.'); } arguments[0].permissions[i] = keys[0] + '|' + $JSON.stringify(a[keys[0]]); } } return arguments; } // Convert complex permissions to strings so they validate against the schema apiFunctions.setUpdateArgumentsPreValidate( 'contains', convertObjectPermissionsToStrings); apiFunctions.setUpdateArgumentsPreValidate( 'remove', convertObjectPermissionsToStrings); apiFunctions.setUpdateArgumentsPreValidate( 'request', convertObjectPermissionsToStrings); // Convert complex permissions back to objects apiFunctions.setCustomCallback('getAll', function(name, request, callback, response) { for (var i = 0; i < response.permissions.length; i += 1) { response.permissions[i] = maybeConvertToObject(response.permissions[i]); } // Since the schema says Permissions.permissions contains strings and // not objects, validation will fail after the for-loop above. This // skips validation and calls the callback directly. if (callback) callback(response); }); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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. var binding = apiBridge || require('binding').Binding.create('printerProvider'); var printerProviderInternal = getInternalApi ? getInternalApi('printerProviderInternal') : require('binding').Binding.create('printerProviderInternal').generate(); var registerArgumentMassager = bindingUtil ? $Function.bind(bindingUtil.registerEventArgumentMassager, bindingUtil) : require('event_bindings').registerArgumentMassager; var blobNatives = requireNative('blob_natives'); var printerProviderSchema = requireNative('schema_registry').GetSchema('printerProvider') var utils = require('utils'); var validate = require('schemaUtils').validate; // Custom bindings for chrome.printerProvider API. // The bindings are used to implement callbacks for the API events. Internally // each event is passed requestId argument used to identify the callback // associated with the event. This argument is massaged out from the event // arguments before dispatching the event to consumers. A callback is appended // to the event arguments. The callback wraps an appropriate // chrome.printerProviderInternal API function that is used to report the event // result from the extension. The function is passed requestId and values // provided by the extension. It validates that the values provided by the // extension match chrome.printerProvider event callback schemas. It also // ensures that a callback is run at most once. In case there is an exception // during event dispatching, the chrome.printerProviderInternal function // is called with a default error value. // // Handles a chrome.printerProvider event as described in the file comment. // |eventName|: The event name. // |prepareArgsForDispatch|: Function called before dispatching the event to // the extension. It's called with original event |args| list and callback // that should be called when the |args| are ready for dispatch. The // callbacks should report whether the argument preparation was successful. // The function should not change the first argument, which contains the // request id. // |resultreporter|: The function that should be called to report event result. // One of chrome.printerProviderInternal API functions. function handleEvent(eventName, prepareArgsForDispatch, resultReporter) { registerArgumentMassager('printerProvider.' + eventName, function(args, dispatch) { var responded = false; // Validates that the result passed by the extension to the event // callback matches the callback schema. Throws an exception in case of // an error. var validateResult = function(result) { var eventSchema = utils.lookup(printerProviderSchema.events, 'name', eventName); var callbackSchema = utils.lookup(eventSchema.parameters, 'type', 'function'); validate([result], callbackSchema.parameters); }; // Function provided to the extension as the event callback argument. // It makes sure that the event result hasn't previously been returned // and that the provided result matches the callback schema. In case of // an error it throws an exception. var reportResult = function(result) { if (responded) throw new Error('Event callback must not be called more than once.'); var finalResult = null; try { validateResult(result); // throws on failure finalResult = result; } finally { responded = true; resultReporter(args[0] /* requestId */, finalResult); } }; prepareArgsForDispatch(args, function(success) { if (!success) { // Do not throw an exception since the extension should not yet be // aware of the event. resultReporter(args[0] /* requestId */, null); return; } dispatch(args.slice(1).concat(reportResult)); }); }); } // Sets up printJob.document property for a print request. function createPrintRequestBlobArguments(args, callback) { printerProviderInternal.getPrintData(args[0] /* requestId */, function(blobInfo) { if (chrome.runtime.lastError) { callback(false); return; } // |args[1]| is printJob. args[1].document = blobNatives.TakeBrowserProcessBlob( blobInfo.blobUuid, blobInfo.type, blobInfo.size); callback(true); }); } handleEvent('onGetPrintersRequested', function(args, callback) { callback(true); }, printerProviderInternal.reportPrinters); handleEvent('onGetCapabilityRequested', function(args, callback) { callback(true); }, printerProviderInternal.reportPrinterCapability); handleEvent('onPrintRequested', createPrintRequestBlobArguments, printerProviderInternal.reportPrintResult); handleEvent('onGetUsbPrinterInfoRequested', function(args, callback) { callback(true); }, printerProviderInternal.reportUsbPrinterInfo); exports.$set('binding', binding.generate()); // 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. // Custom binding for the runtime API. var binding = apiBridge || require('binding').Binding.create('runtime'); var messaging = require('messaging'); var runtimeNatives = requireNative('runtime'); var messagingNatives = requireNative('messaging_natives'); var process = requireNative('process'); var utils = require('utils'); var getBindDirectoryEntryCallback = require('fileEntryBindingUtil').getBindDirectoryEntryCallback; binding.registerCustomHook(function(binding, id, contextType) { var apiFunctions = binding.apiFunctions; var runtime = binding.compiledApi; // // Unprivileged APIs. // if (id != '') utils.defineProperty(runtime, 'id', id); apiFunctions.setHandleRequest('getManifest', function() { return runtimeNatives.GetManifest(); }); apiFunctions.setHandleRequest('getURL', function(path) { path = $String.self(path); if (!path.length || path[0] != '/') path = '/' + path; return 'chrome-extension://' + id + path; }); var sendMessageUpdateArguments = messaging.sendMessageUpdateArguments; apiFunctions.setUpdateArgumentsPreValidate( 'sendMessage', $Function.bind(sendMessageUpdateArguments, null, 'sendMessage', true /* hasOptionsArgument */)); apiFunctions.setUpdateArgumentsPreValidate( 'sendNativeMessage', $Function.bind(sendMessageUpdateArguments, null, 'sendNativeMessage', false /* hasOptionsArgument */)); apiFunctions.setHandleRequest( 'sendMessage', function(targetId, message, options, responseCallback) { var connectOptions = $Object.assign({ __proto__: null, name: messaging.kMessageChannel, }, options); var port = runtime.connect(targetId, connectOptions); messaging.sendMessageImpl(port, message, responseCallback); }); apiFunctions.setHandleRequest('sendNativeMessage', function(targetId, message, responseCallback) { var port = runtime.connectNative(targetId); messaging.sendMessageImpl(port, message, responseCallback); }); apiFunctions.setHandleRequest('connect', function(targetId, connectInfo) { if (!targetId) { // id is only defined inside extensions. If we're in a webpage, the best // we can do at this point is to fail. if (!id) { throw new Error('chrome.runtime.connect() called from a webpage must ' + 'specify an Extension ID (string) for its first ' + 'argument'); } targetId = id; } var name = ''; if (connectInfo && connectInfo.name) name = connectInfo.name; var includeTlsChannelId = !!(connectInfo && connectInfo.includeTlsChannelId); var portId = messagingNatives.OpenChannelToExtension(targetId, name, includeTlsChannelId); if (portId >= 0) return messaging.createPort(portId, name); }); // // Privileged APIs. // if (contextType != 'BLESSED_EXTENSION') return; apiFunctions.setHandleRequest('connectNative', function(nativeAppName) { var portId = messagingNatives.OpenChannelToNativeApp(nativeAppName); if (portId >= 0) return messaging.createPort(portId, ''); throw new Error('Error connecting to native app: ' + nativeAppName); }); apiFunctions.setCustomCallback('getBackgroundPage', function(name, request, callback, response) { if (callback) { var bg = runtimeNatives.GetExtensionViews(-1, -1, 'BACKGROUND')[0] || null; callback(bg); } }); apiFunctions.setCustomCallback('getPackageDirectoryEntry', getBindDirectoryEntryCallback()); }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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 function is returned to DidInitializeServiceWorkerContextOnWorkerThread // then executed, passing in dependencies as function arguments. // // |backgroundUrl| is the URL of the extension's background page. // |wakeEventPage| is a function that wakes up the current extension's event // page, then runs its callback on completion or failure. // |logging| is an object equivalent to a subset of base/debug/logging.h, with // CHECK/DCHECK/etc. (function(backgroundUrl, wakeEventPage, logging) { 'use strict'; self.chrome = self.chrome || {}; self.chrome.runtime = self.chrome.runtime || {}; // Returns a Promise that resolves to the background page's client, or null // if there is no background client. function findBackgroundClient() { return self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(function(clients) { return clients.find(function(client) { return client.url == backgroundUrl; }); }); } // Returns a Promise wrapper around wakeEventPage, that resolves on success, // or rejects on failure. function makeWakeEventPagePromise() { return new Promise(function(resolve, reject) { wakeEventPage(function(success) { if (success) resolve(); else reject('Failed to start background client "' + backgroundUrl + '"'); }); }); } // The chrome.runtime.getBackgroundClient function is documented in // runtime.json. It returns a Promise that resolves to the background page's // client, or is rejected if there is no background client or if the // background client failed to wake. self.chrome.runtime.getBackgroundClient = function() { return findBackgroundClient().then(function(client) { if (client) { // Background client is already awake, or it was persistent. return client; } // Event page needs to be woken. return makeWakeEventPagePromise().then(function() { return findBackgroundClient(); }).then(function(client) { if (!client) { return Promise.reject( 'Background client "' + backgroundUrl + '" not found'); } return client; }); }); }; }); // 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. // Custom binding for the webRequest API. if (!apiBridge) { var binding = require('binding').Binding.create('webRequest'); var webRequestEvent = require('webRequestEvent').WebRequestEvent; binding.registerCustomEvent(webRequestEvent); exports.$set('binding', binding.generate()); } // Copyright 2017 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 CHECK = requireNative('logging').CHECK; var eventBindings = bindingUtil ? undefined : require('event_bindings'); var idGeneratorNatives = requireNative('id_generator'); var utils = require('utils'); var validate = require('schemaUtils').validate; var webRequestInternal = getInternalApi ? getInternalApi('webRequestInternal') : require('binding').Binding.create('webRequestInternal').generate(); function getUniqueSubEventName(eventName) { return eventName + '/' + idGeneratorNatives.GetNextId(); } function createSubEvent(name, argSchemas) { if (bindingUtil) { var supportsFilters = false; var supportsLazyListeners = true; return bindingUtil.createCustomEvent(name, undefined, supportsFilters, supportsLazyListeners); } return new eventBindings.Event(name, argSchemas); } // WebRequestEventImpl object. This is used for special webRequest events // with extra parameters. Each invocation of addListener creates a new named // sub-event. That sub-event is associated with the extra parameters in the // browser process, so that only it is dispatched when the main event occurs // matching the extra parameters. // // Example: // chrome.webRequest.onBeforeRequest.addListener( // callback, {urls: 'http://*.google.com/*'}); // ^ callback will only be called for onBeforeRequests matching the filter. function WebRequestEventImpl(eventName, opt_argSchemas, opt_extraArgSchemas, opt_eventOptions, opt_webViewInstanceId) { if (typeof eventName != 'string') throw new Error('chrome.WebRequestEvent requires an event name.'); this.eventName = eventName; this.argSchemas = opt_argSchemas; this.extraArgSchemas = opt_extraArgSchemas; this.webViewInstanceId = opt_webViewInstanceId || 0; this.subEvents = []; if (eventBindings) { var eventOptions = eventBindings.parseEventOptions(opt_eventOptions); CHECK(!eventOptions.supportsRules, eventName + ' supports rules'); CHECK(eventOptions.supportsListeners, eventName + ' does not support listeners'); } } $Object.setPrototypeOf(WebRequestEventImpl.prototype, null); // Test if the given callback is registered for this event. WebRequestEventImpl.prototype.hasListener = function(cb) { return this.findListener_(cb) > -1; }; // Test if any callbacks are registered fur thus event. WebRequestEventImpl.prototype.hasListeners = function() { return this.subEvents.length > 0; }; // Registers a callback to be called when this event is dispatched. If // opt_filter is specified, then the callback is only called for events that // match the given filters. If opt_extraInfo is specified, the given optional // info is sent to the callback. WebRequestEventImpl.prototype.addListener = function(cb, opt_filter, opt_extraInfo) { // NOTE(benjhayden) New APIs should not use this subEventName trick! It does // not play well with event pages. See downloads.onDeterminingFilename and // ExtensionDownloadsEventRouter for an alternative approach. var subEventName = getUniqueSubEventName(this.eventName); // Note: this could fail to validate, in which case we would not add the // subEvent listener. validate($Array.slice(arguments, 1), this.extraArgSchemas); webRequestInternal.addEventListener( cb, opt_filter, opt_extraInfo, this.eventName, subEventName, this.webViewInstanceId); var subEvent = createSubEvent(subEventName, this.argSchemas); var subEventCallback = cb; if (opt_extraInfo && opt_extraInfo.indexOf('blocking') >= 0) { var eventName = this.eventName; subEventCallback = function() { var requestId = arguments[0].requestId; try { var result = $Function.apply(cb, null, arguments); webRequestInternal.eventHandled( eventName, subEventName, requestId, result); } catch (e) { webRequestInternal.eventHandled( eventName, subEventName, requestId); throw e; } }; } else if (opt_extraInfo && opt_extraInfo.indexOf('asyncBlocking') >= 0) { var eventName = this.eventName; subEventCallback = function() { var details = arguments[0]; var requestId = details.requestId; var handledCallback = function(response) { webRequestInternal.eventHandled( eventName, subEventName, requestId, response); }; $Function.apply(cb, null, [details, handledCallback]); }; } $Array.push(this.subEvents, {subEvent: subEvent, callback: cb, subEventCallback: subEventCallback}); subEvent.addListener(subEventCallback); }; // Unregisters a callback. WebRequestEventImpl.prototype.removeListener = function(cb) { var idx; while ((idx = this.findListener_(cb)) >= 0) { var e = this.subEvents[idx]; e.subEvent.removeListener(e.subEventCallback); if (e.subEvent.hasListeners()) { console.error( 'Internal error: webRequest subEvent has orphaned listeners.'); } $Array.splice(this.subEvents, idx, 1); } }; WebRequestEventImpl.prototype.findListener_ = function(cb) { for (var i in this.subEvents) { var e = this.subEvents[i]; if (e.callback === cb) { if (e.subEvent.hasListener(e.subEventCallback)) return i; console.error('Internal error: webRequest subEvent has no callback.'); } } return -1; }; WebRequestEventImpl.prototype.addRules = function(rules, opt_cb) { throw new Error('This event does not support rules.'); }; WebRequestEventImpl.prototype.removeRules = function(ruleIdentifiers, opt_cb) { throw new Error('This event does not support rules.'); }; WebRequestEventImpl.prototype.getRules = function(ruleIdentifiers, cb) { throw new Error('This event does not support rules.'); }; function WebRequestEvent() { privates(WebRequestEvent).constructPrivate(this, arguments); } // Our util code requires we construct a new WebRequestEvent via a call to // 'new WebRequestEvent', which wouldn't work well with calling a v8::Function. // Provide a wrapper for native bindings to call into. function createWebRequestEvent(eventName, opt_argSchemas, opt_extraArgSchemas, opt_eventOptions, opt_webViewInstanceId) { return new WebRequestEvent(eventName, opt_argSchemas, opt_extraArgSchemas, opt_eventOptions, opt_webViewInstanceId); } utils.expose(WebRequestEvent, WebRequestEventImpl, { functions: [ 'hasListener', 'hasListeners', 'addListener', 'removeListener', 'addRules', 'removeRules', 'getRules', ], }); exports.$set('WebRequestEvent', WebRequestEvent); exports.$set('createWebRequestEvent', createWebRequestEvent); // 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. // Custom binding for the webViewRequest API. var binding = apiBridge || require('binding').Binding.create('webViewRequest'); var declarativeWebRequestSchema = requireNative('schema_registry').GetSchema('declarativeWebRequest'); var utils = require('utils'); var validate = require('schemaUtils').validate; binding.registerCustomHook(function(api) { var webViewRequest = api.compiledApi; // Returns the schema definition of type |typeId| defined in // |declarativeWebRequestScheme.types|. function getSchema(typeId) { return utils.lookup(declarativeWebRequestSchema.types, 'id', 'declarativeWebRequest.' + typeId); } // Helper function for the constructor of concrete datatypes of the // declarative webRequest API. // Makes sure that |this| contains the union of parameters and // {'instanceType': 'declarativeWebRequest.' + typeId} and validates the // generated union dictionary against the schema for |typeId|. function setupInstance(instance, parameters, typeId) { for (var key in parameters) { if ($Object.hasOwnProperty(parameters, key)) { instance[key] = parameters[key]; } } instance.instanceType = 'declarativeWebRequest.' + typeId; var schema = getSchema(typeId); validate([instance], [schema]); } // Setup all data types for the declarative webRequest API from the schema. for (var i = 0; i < declarativeWebRequestSchema.types.length; ++i) { var typeSchema = declarativeWebRequestSchema.types[i]; var typeId = typeSchema.id.replace('declarativeWebRequest.', ''); var action = function(typeId) { return function(parameters) { setupInstance(this, parameters, typeId); }; }(typeId); webViewRequest[typeId] = action; } }); if (!apiBridge) exports.$set('binding', binding.generate()); // 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 normalizeArgumentsAndValidate = require('schemaUtils').normalizeArgumentsAndValidate var sendRequest = require('sendRequest').sendRequest; function extendSchema(schema) { var extendedSchema = $Array.slice(schema); $Array.unshift(extendedSchema, {'type': 'string'}); return extendedSchema; } // TODO(devlin): Combine parts of this and other custom types (ChromeSetting, // ContentSetting, etc). function StorageArea(namespace, schema) { // Binds an API function for a namespace to its browser-side call, e.g. // storage.sync.get('foo') -> (binds to) -> // storage.get('sync', 'foo'). var self = this; function bindApiFunction(functionName) { var rawFunSchema = $Array.filter(schema.functions, function(f) { return f.name === functionName; })[0]; // normalizeArgumentsAndValidate expects a function schema of the form // { name: , definition: }. var funSchema = { __proto__: null, name: rawFunSchema.name, definition: rawFunSchema }; self[functionName] = function() { var args = $Array.slice(arguments); args = normalizeArgumentsAndValidate(args, funSchema); return sendRequest( 'storage.' + functionName, $Array.concat([namespace], args), extendSchema(funSchema.definition.parameters), {__proto__: null, preserveNullInObjects: true}); }; } var apiFunctions = ['get', 'set', 'remove', 'clear', 'getBytesInUse']; $Array.forEach(apiFunctions, bindApiFunction); } exports.$set('StorageArea', StorageArea); /* * 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. * * A style sheet for Chrome apps. */ @namespace "http://www.w3.org/1999/xhtml"; body { -webkit-user-select: none; cursor: default; font-family: $FONTFAMILY; font-size: $FONTSIZE; } webview, appview { display: inline-block; width: 300px; height: 300px; } html, body { overflow: hidden; } img, a { -webkit-user-drag: none; } [contenteditable], input { -webkit-user-select: auto; } // 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 logging = requireNative('logging'); /** * Returns a function that logs a 'not available' error to the console and * returns undefined. * * @param {string} messagePrefix text to prepend to the exception message. */ function generateDisabledMethodStub(messagePrefix, opt_messageSuffix) { var message = messagePrefix + ' is not available in packaged apps.'; if (opt_messageSuffix) message = message + ' ' + opt_messageSuffix; return function() { console.error(message); return; }; } /** * Returns a function that throws a 'not available' error. * * @param {string} messagePrefix text to prepend to the exception message. */ function generateThrowingMethodStub(messagePrefix, opt_messageSuffix) { var message = messagePrefix + ' is not available in packaged apps.'; if (opt_messageSuffix) message = message + ' ' + opt_messageSuffix; return function() { throw new Error(message); }; } /** * Replaces the given methods of the passed in object with stubs that log * 'not available' errors to the console and return undefined. * * This should be used on methods attached via non-configurable properties, * such as window.alert. disableGetters should be used when possible, because * it is friendlier towards feature detection. * * In most cases, the useThrowingStubs should be false, so the stubs used to * replace the methods log an error to the console, but allow the calling code * to continue. We shouldn't break library code that uses feature detection * responsibly, such as: * if(window.confirm) { * var result = window.confirm('Are you sure you want to delete ...?'); * ... * } * * useThrowingStubs should only be true for methods that are deprecated in the * Web platform, and should not be used by a responsible library, even in * conjunction with feature detection. A great example is document.write(), as * the HTML5 specification recommends against using it, and says that its * behavior is unreliable. No reasonable library code should ever use it. * HTML5 spec: http://www.w3.org/TR/html5/dom.html#dom-document-write * * @param {Object} object The object with methods to disable. The prototype is * preferred. * @param {string} objectName The display name to use in the error message * thrown by the stub (this is the name that the object is commonly referred * to by web developers, e.g. "document" instead of "HTMLDocument"). * @param {Array} methodNames names of methods to disable. * @param {Boolean} useThrowingStubs if true, the replaced methods will throw * an error instead of silently returning undefined */ function disableMethods(object, objectName, methodNames, useThrowingStubs) { $Array.forEach(methodNames, function(methodName) { logging.DCHECK($Object.getOwnPropertyDescriptor(object, methodName), objectName + ': ' + methodName); var messagePrefix = objectName + '.' + methodName + '()'; $Object.defineProperty(object, methodName, { configurable: false, enumerable: false, value: useThrowingStubs ? generateThrowingMethodStub(messagePrefix) : generateDisabledMethodStub(messagePrefix) }); }); } /** * Replaces the given properties of the passed in object with stubs that log * 'not available' warnings to the console and return undefined when gotten. If * a property's setter is later invoked, the getter and setter are restored to * default behaviors. * * @param {Object} object The object with properties to disable. The prototype * is preferred. * @param {string} objectName The display name to use in the error message * thrown by the getter stub (this is the name that the object is commonly * referred to by web developers, e.g. "document" instead of * "HTMLDocument"). * @param {Array} propertyNames names of properties to disable. * @param {?string=} opt_messageSuffix An optional suffix for the message. * @param {boolean=} opt_ignoreMissingProperty True if we allow disabling * getters for non-existent properties. */ function disableGetters(object, objectName, propertyNames, opt_messageSuffix, opt_ignoreMissingProperty) { $Array.forEach(propertyNames, function(propertyName) { logging.DCHECK(opt_ignoreMissingProperty || $Object.getOwnPropertyDescriptor(object, propertyName), objectName + ': ' + propertyName); var stub = generateDisabledMethodStub(objectName + '.' + propertyName, opt_messageSuffix); stub._is_platform_app_disabled_getter = true; $Object.defineProperty(object, propertyName, { configurable: true, enumerable: false, get: stub, set: function(value) { var descriptor = $Object.getOwnPropertyDescriptor(this, propertyName); if (!descriptor || !descriptor.get || descriptor.get._is_platform_app_disabled_getter) { // The stub getter is still defined. Blow-away the property to // restore default getter/setter behaviors and re-create it with the // given value. delete this[propertyName]; this[propertyName] = value; } else { // Do nothing. If some custom getter (not ours) has been defined, // there would be no way to read back the value stored by a default // setter. Also, the only way to clear a custom getter is to first // delete the property. Therefore, the value we have here should // just go into a black hole. } } }); }); } /** * Replaces the given properties of the passed in object with stubs that log * 'not available' warnings to the console when set. * * @param {Object} object The object with properties to disable. The prototype * is preferred. * @param {string} objectName The display name to use in the error message * thrown by the setter stub (this is the name that the object is commonly * referred to by web developers, e.g. "document" instead of * "HTMLDocument"). * @param {Array} propertyNames names of properties to disable. */ function disableSetters(object, objectName, propertyNames, opt_messageSuffix) { $Array.forEach(propertyNames, function(propertyName) { logging.DCHECK($Object.getOwnPropertyDescriptor(object, propertyName), objectName + ': ' + propertyName); var stub = generateDisabledMethodStub(objectName + '.' + propertyName, opt_messageSuffix); $Object.defineProperty(object, propertyName, { configurable: false, enumerable: false, get: function() { return; }, set: stub }); }); } // Disable benign Document methods. disableMethods(Document.prototype, 'document', ['open', 'close']); disableMethods(Document.prototype, 'document', ['clear']); // Replace evil Document methods with exception-throwing stubs. disableMethods(Document.prototype, 'document', ['write', 'writeln'], true); // Disable history. Object.defineProperty(window, "history", { value: {} }); // Note: we just blew away the history object, so we need to ignore the fact // that these properties aren't defined on the object. disableGetters(window.history, 'history', ['back', 'forward', 'go', 'length', 'pushState', 'replaceState', 'state'], null, true); // Disable find. disableMethods(window, 'window', ['find']); // Disable modal dialogs. Shell windows disable these anyway, but it's nice to // warn. disableMethods(window, 'window', ['alert', 'confirm', 'prompt']); // Disable window.*bar. disableGetters(window, 'window', ['locationbar', 'menubar', 'personalbar', 'scrollbars', 'statusbar', 'toolbar']); // Disable window.localStorage. // Sometimes DOM security policy prevents us from doing this (e.g. for data: // URLs) so wrap in try-catch. try { disableGetters(window, 'window', ['localStorage'], 'Use chrome.storage.local instead.'); } catch (e) {} // Document instance properties that we wish to disable need to be set when // the document begins loading, since only then will the "document" reference // point to the page's document (it will be reset between now and then). // We can't listen for the "readystatechange" event on the document (because // the object that it's dispatched on doesn't exist yet), but we can instead // do it at the window level in the capturing phase. window.addEventListener('readystatechange', function(event) { if (document.readyState != 'loading') return; // Deprecated document properties from // https://developer.mozilla.org/en/DOM/document. // Disable document.all so that platform apps can not access. delete Document.prototype.all disableGetters(document, 'document', ['alinkColor', 'all', 'bgColor', 'fgColor', 'linkColor', 'vlinkColor'], null, true); }, true); // Disable onunload, onbeforeunload. disableSetters(window, 'window', ['onbeforeunload', 'onunload']); var eventTargetAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type) { var args = $Array.slice(arguments); // Note: Force conversion to a string in order to catch any funny attempts // to pass in something that evals to 'unload' but wouldn't === 'unload'. var type = (args[0] += ''); if (type === 'unload' || type === 'beforeunload') generateDisabledMethodStub(type)(); else return $Function.apply(eventTargetAddEventListener, this, args); }; /* * 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. * * This stylesheet is used to apply Chrome system fonts to all extension pages. */ body { font-family: $FONTFAMILY; font-size: $FONTSIZE; } /* * 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. * * This stylesheet is used to apply Chrome styles to extension pages that opt in * to using them. * * These styles have been copied from ui/webui/resources/css/chrome_shared.css * and ui/webui/resources/css/widgets.css *with CSS class logic removed*, so * that it's as close to a user-agent stylesheet as possible. * * For example, extensions shouldn't be able to set a .link-button class and * have it do anything. * * Other than that, keep this file and chrome_shared.css/widgets.cc in sync as * much as possible. */ body { color: #333; cursor: default; /* Note that the correct font-family and font-size are set in * extension_fonts.css. */ /* This top margin of 14px matches the top padding on the h1 element on * overlays (see the ".overlay .page h1" selector in overlay.css), which * every dialogue has. * * Similarly, the bottom 14px margin matches the bottom padding of the area * which hosts the buttons (see the ".overlay .page * .action-area" selector * in overlay.css). * * Both have a padding left/right of 17px. * * Note that we're putting this here in the Extension content, rather than * the WebUI element which contains the content, so that scrollbars in the * Extension content don't get a 6px margin, which looks quite odd. */ margin: 14px 17px; } p { line-height: 1.8em; } h1, h2, h3 { -webkit-user-select: none; font-weight: normal; /* Makes the vertical size of the text the same for all fonts. */ line-height: 1; } h1 { font-size: 1.5em; } h2 { font-size: 1.3em; margin-bottom: 0.4em; } h3 { color: black; font-size: 1.2em; margin-bottom: 0.8em; } a { color: rgb(17, 85, 204); text-decoration: underline; } a:active { color: rgb(5, 37, 119); } /* Default state **************************************************************/ :-webkit-any(button, input[type='button'], input[type='submit']), select, input[type='checkbox'], input[type='radio'] { -webkit-appearance: none; -webkit-user-select: none; background-image: linear-gradient(#ededed, #ededed 38%, #dedede); border: 1px solid rgba(0, 0, 0, 0.25); border-radius: 2px; box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); color: #444; font: inherit; margin: 0 1px 0 0; outline: none; text-shadow: 0 1px 0 rgb(240, 240, 240); } :-webkit-any(button, input[type='button'], input[type='submit']), select { min-height: 2em; min-width: 4em; /* The following platform-specific rule is necessary to get adjacent * buttons, text inputs, and so forth to align on their borders while also * aligning on the text's baselines. */ padding-bottom: 1px; } :-webkit-any(button, input[type='button'], input[type='submit']) { -webkit-padding-end: 10px; -webkit-padding-start: 10px; } select { -webkit-appearance: none; -webkit-padding-end: 20px; -webkit-padding-start: 6px; /* OVERRIDE */ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAICAQAAACxSAwfAAAAUklEQVQY02P4z0AMRGZGMaShwCisyhITmb8huMzfEhOxKvuvsGAh208Ik+3ngoX/FbBbClcIUcSAw21QhXxfIIrwKAMpfNsEUYRXGVCEFc6CQwBqq4CCCtU4VgAAAABJRU5ErkJggg==), linear-gradient(#ededed, #ededed 38%, #dedede); background-position: right center; background-repeat: no-repeat; } html[dir='rtl'] select { background-position: center left; } input[type='checkbox'] { height: 13px; position: relative; vertical-align: middle; width: 13px; } input[type='radio'] { /* OVERRIDE */ border-radius: 100%; height: 15px; position: relative; vertical-align: middle; width: 15px; } /* TODO(estade): add more types here? */ input[type='number'], input[type='password'], input[type='search'], input[type='text'], input[type='url'], input:not([type]), textarea { border: 1px solid #bfbfbf; border-radius: 2px; box-sizing: border-box; color: #444; font: inherit; margin: 0; /* Use min-height to accommodate addditional padding for touch as needed. */ min-height: 2em; padding: 3px; outline: none; /* For better alignment between adjacent buttons and inputs. */ padding-bottom: 4px; } input[type='search'] { -webkit-appearance: textfield; /* NOTE: Keep a relatively high min-width for this so we don't obscure the end * of the default text in relatively spacious languages (i.e. German). */ min-width: 160px; } /* Checked ********************************************************************/ input[type='checkbox']:checked::before { -webkit-user-select: none; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAQAAAADpb+tAAAAaElEQVR4Xl3PIQoCQQCF4Y8JW42D1bDZ4iVEjDbxFpstYhC7eIVBZHkXFGw734sv/TqDQQ8Xb1udja/I8igeIm7Aygj2IpoKTGZnVRNxAHYi4iPiDlA9xX+aNQDFySziqDN6uSp6y7ofEMwZ05uUZRkAAAAASUVORK5CYII=); background-size: 100% 100%; content: ''; display: block; height: 100%; width: 100%; } input[type='radio']:checked::before { background-color: #666; border-radius: 100%; bottom: 3px; content: ''; display: block; left: 3px; position: absolute; right: 3px; top: 3px; } /* Hover **********************************************************************/ :enabled:hover:-webkit-any( select, input[type='checkbox'], input[type='radio'], :-webkit-any( button, input[type='button'], input[type='submit'])) { background-image: linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); border-color: rgba(0, 0, 0, 0.3); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95); color: black; } :enabled:hover:-webkit-any(select) { /* OVERRIDE */ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAICAQAAACxSAwfAAAAUklEQVQY02P4z0AMRGZGMaShwCisyhITmb8huMzfEhOxKvuvsGAh208Ik+3ngoX/FbBbClcIUcSAw21QhXxfIIrwKAMpfNsEUYRXGVCEFc6CQwBqq4CCCtU4VgAAAABJRU5ErkJggg==), linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); } /* Active *********************************************************************/ :enabled:active:-webkit-any( select, input[type='checkbox'], input[type='radio'], :-webkit-any( button, input[type='button'], input[type='submit'])) { background-image: linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); box-shadow: none; text-shadow: none; } :enabled:active:-webkit-any(select) { /* OVERRIDE */ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAICAQAAACxSAwfAAAAUklEQVQY02P4z0AMRGZGMaShwCisyhITmb8huMzfEhOxKvuvsGAh208Ik+3ngoX/FbBbClcIUcSAw21QhXxfIIrwKAMpfNsEUYRXGVCEFc6CQwBqq4CCCtU4VgAAAABJRU5ErkJggg==), linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); } /* Disabled *******************************************************************/ :disabled:-webkit-any( button, input[type='button'], input[type='submit']), select:disabled { background-image: linear-gradient(#f1f1f1, #f1f1f1 38%, #e6e6e6); border-color: rgba(80, 80, 80, 0.2); box-shadow: 0 1px 0 rgba(80, 80, 80, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); color: #aaa; } select:disabled { /* OVERRIDE */ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAICAQAAACxSAwfAAAASklEQVQY02P4z0AMRGZGMaShwCisyhITG/4jw8RErMr+KyxYiFC0YOF/BeyWIikEKWLA4Ta4QogiPMpACt82QRThVQYUYYWz4BAAGr6Ii6kEPacAAAAASUVORK5CYII=), linear-gradient(#f1f1f1, #f1f1f1 38%, #e6e6e6); } input:disabled:-webkit-any([type='checkbox'], [type='radio']) { opacity: .75; } input:disabled:-webkit-any([type='password'], [type='search'], [type='text'], [type='url'], :not([type])) { color: #999; } /* Focus **********************************************************************/ :enabled:focus:-webkit-any( select, input[type='checkbox'], input[type='number'], input[type='password'], input[type='radio'], input[type='search'], input[type='text'], input[type='url'], input:not([type]), :-webkit-any( button, input[type='button'], input[type='submit'])) { /* OVERRIDE */ -webkit-transition: border-color 200ms; /* We use border color because it follows the border radius (unlike outline). * This is particularly noticeable on mac. */ border-color: rgb(77, 144, 254); outline: none; } /* Checkbox/radio helpers ****************************************************** * * .checkbox and .radio classes wrap labels. Checkboxes and radios should use * these classes with the markup structure: * *
* *
*/ :-webkit-any(.checkbox, .radio) label { /* Don't expand horizontally: . */ align-items: center; display: inline-flex; padding-bottom: 7px; padding-top: 7px; } :-webkit-any(.checkbox, .radio) label input { flex-shrink: 0; } :-webkit-any(.checkbox, .radio) label input ~ span { -webkit-margin-start: 0.6em; /* Make sure long spans wrap at the same horizontal position they start. */ display: block; } :-webkit-any(.checkbox, .radio) label:hover { color: black; } label > input:disabled:-webkit-any([type='checkbox'], [type='radio']) ~ span { color: #999; } Headless remote debugging
Inspectable WebContents
{ "name": "content_browser", "interface_provider_specs": { "service_manager:connector": { "requires": { "pdf_compositor": [ "compositor" ] } }, "navigation:frame": { "provides": { "renderer": [] } } } }{ "name": "content_renderer", "interface_provider_specs": { "navigation:frame": { "provides": { "browser": [ "headless::HeadlessRenderFrameController" ] } } } } { "services": [ { "display_name": "PDF Compositor Service", "sandbox_type": "pdf_compositor", "name": "pdf_compositor", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ], "service_manager": [ "service_manager:all_users" ] }, "provides": { "compositor": [ "printing::mojom::PdfCompositor" ] } } } } ], "name": "content_packaged_services", "interface_provider_specs": {} }// 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() { var mojomId = 'headless/lib/tab_socket.mojom'; if (mojo.internal.isMojomLoaded(mojomId)) { console.warn('The following mojom is loaded multiple times: ' + mojomId); return; } mojo.internal.markMojomLoaded(mojomId); var bindings = mojo; var associatedBindings = mojo; var codec = mojo.internal; var validator = mojo.internal; var exports = mojo.internal.exposeNamespace('headless'); function TabSocket_SendMessageToEmbedder_Params(values) { this.initDefaults_(); this.initFields_(values); } TabSocket_SendMessageToEmbedder_Params.prototype.initDefaults_ = function() { this.message = null; this.v8ExecutionContextId = 0; }; TabSocket_SendMessageToEmbedder_Params.prototype.initFields_ = function(fields) { for(var field in fields) { if (this.hasOwnProperty(field)) this[field] = fields[field]; } }; TabSocket_SendMessageToEmbedder_Params.validate = function(messageValidator, offset) { var err; err = messageValidator.validateStructHeader(offset, codec.kStructHeaderSize); if (err !== validator.validationError.NONE) return err; var kVersionSizes = [ {version: 0, numBytes: 24} ]; err = messageValidator.validateStructVersion(offset, kVersionSizes); if (err !== validator.validationError.NONE) return err; // validate TabSocket_SendMessageToEmbedder_Params.message err = messageValidator.validateStringPointer(offset + codec.kStructHeaderSize + 0, false) if (err !== validator.validationError.NONE) return err; return validator.validationError.NONE; }; TabSocket_SendMessageToEmbedder_Params.encodedSize = codec.kStructHeaderSize + 16; TabSocket_SendMessageToEmbedder_Params.decode = function(decoder) { var packed; var val = new TabSocket_SendMessageToEmbedder_Params(); var numberOfBytes = decoder.readUint32(); var version = decoder.readUint32(); val.message = decoder.decodeStruct(codec.String); val.v8ExecutionContextId = decoder.decodeStruct(codec.Int32); decoder.skip(1); decoder.skip(1); decoder.skip(1); decoder.skip(1); return val; }; TabSocket_SendMessageToEmbedder_Params.encode = function(encoder, val) { var packed; encoder.writeUint32(TabSocket_SendMessageToEmbedder_Params.encodedSize); encoder.writeUint32(0); encoder.encodeStruct(codec.String, val.message); encoder.encodeStruct(codec.Int32, val.v8ExecutionContextId); encoder.skip(1); encoder.skip(1); encoder.skip(1); encoder.skip(1); }; var kTabSocket_SendMessageToEmbedder_Name = 1129853368; function TabSocketPtr(handleOrPtrInfo) { this.ptr = new bindings.InterfacePtrController(TabSocket, handleOrPtrInfo); } function TabSocketAssociatedPtr(associatedInterfacePtrInfo) { this.ptr = new associatedBindings.AssociatedInterfacePtrController( TabSocket, associatedInterfacePtrInfo); } TabSocketAssociatedPtr.prototype = Object.create(TabSocketPtr.prototype); TabSocketAssociatedPtr.prototype.constructor = TabSocketAssociatedPtr; function TabSocketProxy(receiver) { this.receiver_ = receiver; } TabSocketPtr.prototype.sendMessageToEmbedder = function() { return TabSocketProxy.prototype.sendMessageToEmbedder .apply(this.ptr.getProxy(), arguments); }; TabSocketProxy.prototype.sendMessageToEmbedder = function(message, v8ExecutionContextId) { var params = new TabSocket_SendMessageToEmbedder_Params(); params.message = message; params.v8ExecutionContextId = v8ExecutionContextId; var builder = new codec.MessageV0Builder( kTabSocket_SendMessageToEmbedder_Name, codec.align(TabSocket_SendMessageToEmbedder_Params.encodedSize)); builder.encodeStruct(TabSocket_SendMessageToEmbedder_Params, params); var message = builder.finish(); this.receiver_.accept(message); }; function TabSocketStub(delegate) { this.delegate_ = delegate; } TabSocketStub.prototype.sendMessageToEmbedder = function(message, v8ExecutionContextId) { return this.delegate_ && this.delegate_.sendMessageToEmbedder && this.delegate_.sendMessageToEmbedder(message, v8ExecutionContextId); } TabSocketStub.prototype.accept = function(message) { var reader = new codec.MessageReader(message); switch (reader.messageName) { case kTabSocket_SendMessageToEmbedder_Name: var params = reader.decodeStruct(TabSocket_SendMessageToEmbedder_Params); this.sendMessageToEmbedder(params.message, params.v8ExecutionContextId); return true; default: return false; } }; TabSocketStub.prototype.acceptWithResponder = function(message, responder) { var reader = new codec.MessageReader(message); switch (reader.messageName) { default: return false; } }; function validateTabSocketRequest(messageValidator) { var message = messageValidator.message; var paramsClass = null; switch (message.getName()) { case kTabSocket_SendMessageToEmbedder_Name: if (!message.expectsResponse() && !message.isResponse()) paramsClass = TabSocket_SendMessageToEmbedder_Params; break; } if (paramsClass === null) return validator.validationError.NONE; return paramsClass.validate(messageValidator, messageValidator.message.getHeaderNumBytes()); } function validateTabSocketResponse(messageValidator) { return validator.validationError.NONE; } var TabSocket = { name: 'headless::TabSocket', kVersion: 0, ptrClass: TabSocketPtr, proxyClass: TabSocketProxy, stubClass: TabSocketStub, validateRequest: validateTabSocketRequest, validateResponse: null, }; TabSocketStub.prototype.validator = validateTabSocketRequest; TabSocketProxy.prototype.validator = null; exports.TabSocket = TabSocket; exports.TabSocketPtr = TabSocketPtr; exports.TabSocketAssociatedPtr = TabSocketAssociatedPtr; })();{ "sandbox_type": "pdf_compositor", "display_name": "PDF Compositor Service", "name": "pdf_compositor", "interface_provider_specs": { "service_manager:connector": { "requires": { "*": [ "app" ], "service_manager": [ "service_manager:all_users" ] }, "provides": { "compositor": [ "printing::mojom::PdfCompositor" ] } } } }
$i18nRaw{listingParsingErrorBoxText}

$i18n{header}

$i18n{headerName} $i18n{headerSize} $i18n{headerDateModified}
i6hHk2ݢ~ JmP"QsAR=Gv~-HN뒤4z\}ppy&_~zɜ[}꒓Up޵юo,DAۜJ4I&9u8+Z>ybtDdO.T[V/c&wLƛ$[4cdn[TpQj|X."c[ u48QUt?hTg-I%WmEm]GZK9]Df@j3_\D9a"*"i*(ܕx kX&w#>{8I8mIU% `*|dղ| ; ۦ =Mu4n폕OE!i+b!t*x}_EMMg -t#IeL8-iaXb,X N$zC>#HHDr+slmF$ρ9)=^ރ>HƁ:wI@W\ Ӟ7sj@vVx`\7*ߏH{̿\cGC~.7(WGD,3D]bl2RT ›{Zj0<\QS 5TJM:`8r Jh~6zZ3) TUl4+{XfB(xLBIQKy!c !Ry8v:a Zt=F1w7 Ҳ^"m 8Qڥ Fte:۷~ ѢNAhu鎦wy)Rm}  OjӴ$goG处Mj6f#(E1–)1+'a:2aԖ^ya#KFʥҞ>ۍ uKgR%"X_3DvLaRdߑpc]~]Ϭwx b; l 'K&޴·r5'H(:)Tx9rzFۆAhp㐖&ON㽚2#P(UoIYoV0JES&bb@g+&;aED)Unm_ѹU1`pN2"ݳ!1Aw=tu2፜rh[Q'qhvDM=.ZY z}?5Rч= g[]R b 'HNuO,6ix'#v{n,Gy,BF8.=fi7*lDcL0ϦaRG=?&#ߜq6:Upe `O)B}UHտ<2uѻEQo=Jb%gV6* R%K`4+2)˳ϟi1|m9Z9Ծ^!U)Ʃ1'NO2رLLr ,@!qw2Y>Pzw2ISB)g?oQdQ"Y'?VN=;HQ8 ܬI!&,a\ SS5Y6 Wp>p<>'kncBW#D?Jm}ћd vzj:C E]t[;bb{{T\RuDbXkBb{;Eg9익*1_ HXYrIίwjaC_B0s󄵟nS[فRiQd38nQ9y'Ȳ:zvOyﴸOE~\;DLl8&*^]ɷ aP*υ^xHN= mTY8!uu) X{e9jˉg;}2eЉl;YD Ӝb|J4'qM'pz5.oM nsJ 57P450ơm(yDl(٤,D'MK 3洮٩(BҎ+K|2ܼ:!!D-y-<_NYځ1g´͚: 2PgqeLjB\B>F/ZMJo*JU_D)7i;Z„"Je(zQP{yj{e,0ESw ($7퐹 ac^."^%0C`A#o@xomy;.kOC3ڄmqXVǘjg7wVV Dv¦"9YdduF KG <8^քS@_c*& uv:/98<q˖ +zM]L; wVhEu+k=jETɽs ѵ82[dǵ_pgr7}H!Nn`'E{~M޻fTƽfMi3;sEgWCH5aD1]Q&F gVW2+RmƪEc'|J1Vn?:?RZUHh-J-Rrl7C HɅ&- gaGYRhey+k$= Ppɳ-<܌h pÚ-J.P.Y;& 0E=!.^#7lln$c!|1ҮI  ߫Bpa*TV>KP|8?(1Llރ/ ny[(YVJuY}.`8Clt=R~B)lp0L'4~-)-QsҀΌ Y a4|:!AK쿛2zˆwb{vҼpp29il%$M74s&B?)ia 5qRi'vItW ݠ`ʕeRؕQKPr?P$?m'{GNjWL,oG Iq1z\Ub_)!Ӱ <hۇGPs}R( !|aA.dKz GN~P.7JL{a=_el1ؿI om<$rzֿDGX`IBm\`WȄe5PEVGolXy v]?G!͈K~T}S4h2.%⒵iQW2 pAտċzE.Z6}mN]pW{.Vms8ίMC&@^:p٦|hb[Y[&@ ]8쳯qNj`^ER<Ȋ7[v,AВG@a4al@%C"zgr"V0"+ X29._ % ( PP1)i,8iʗ,A)R(JK;J5'((%# rUdQ?9,JRS0ڽ]Nh4N ˨P1>453Y 5REFsII;|p<L<#i Nvc] !;;pף>\݃= qXN^߾! ~{= 0?v4tQۋ\'Ԋqn G [7B7@5[' _ѽzF2wlAC;P(q0C?}7 mcEF^HBr}ڠwӋfð!͡鹸"[vpo l5F9<}kߠG?*R\CWzH;"09aC{-t0I*2_[9߽h[ Mb"a s3to(_ݹp+w=F@n53tbUkye֩ǭpqS%|hLx 3[nhH2YX,Y"t?.Ƅ ,Hr$ջR~cY$5S+!9'd,]Y8>smVgHV9Ԏ$IG*p$)! )ir[ja89!C"xa@/ 1IiQM(u,/ K.ZqYQkI'L*+Uqoܺ|YNNQ+<1craMgm֟NfŌ#xShO?=fހX4E-9|5J"AxW,OfHT>zNk7wr-\WVN34k=o];s6{ e̝T~XքnqH%^3)#IYVJJis&uiV+PÖz{_TҘcHS8V1[}v*؂njNiVdkKouͩGdYޅ:HԀ/Sy~#U @6 l *rX8O&>#Uo:ph|imlK~okߍ3 =fsA}-kfn*שasRZ}_̳lo©5,}S*[Ү&/ O_UMo6W fa'P=mP$WEDD$Ҡ8FZI|h ؊f{oF Zo| ۗ`j̺uyA]`e+α*3YY2k%( l)ael`sҘJT)cJJ(d[kUn#~IĩkSz ѕ0Ke9Iw"Ef99Xc<4~59a,H $O@ H҂@b~ ,ҁoc%bY81yq[NxPIP +x l H_0Αv͒Y/Nz4˅1C8fΫx‚d=_h"Y}a(*n=l~_`! )j;8p+⎖q^bQ0imYvC_B޸E<((Q8"B,BVG4!͇aivK䇟nf gdw- ;rt/,Ӱ%=년Mc>eI(!%3)vА:tO 9qrޯ/cqqrr6xC5=Z<S;k|#'Oqޥ(t6v9'>=b_wyH^:ZM;GN.V +t)GB.ܥrjUOW8m-^JJ᪖ r\4=!|^ԝ aJD|eAGVk[+㿿;. ]J1 ).tEaAߣF'&%:nב2'99QDw2:jPD@&) Q"D ,u00}"Q`iL6"<:[ fdZQ1-Qk3U?$RPM-iOL <JuAm$w7ۻ Xw(Qlmq 2BN}pyZE.KCfA߫wkUn6+KpEEEDd%Fу,1 4(*YwV6.=ԇg޼f48g95,{4\:=xkKLL[oC0Bm[z Bdklp‹ |vC kͽij˜B5ߙt {MX܋8J))"p3RϷ{pj }@QFƄ]o3]~u4zצ㴲m6;/L Jնʅ_\iTٺfiצ]a?xA{SwaxINlN .E8жŧF\ptǰE8p}x' nh0y({Pz T1/,nESmCR7R,%,,RS|R̪`>07XKj j "O*b yQH&VȲgB1e3rKESy! R$U$+.җ dLxz.[,%.4ʅGlq*զB$YoMDٔԚ' GQLn#W[ƳvT;Z*EYEF"3dUG2)+Y,(hFyV)-yɥ֥(s:Lf"'41%썮1C{Iy%(M/|N ϱyBQq"lSӐ[l8OcwÁ8JKZ\M&W(=kǍ0n |OEk5$JaX2c/םn  cqغ vu_ -b`\:{ι'\yOp]Zv1ֻ=n\m,w x[7O h\ "ȼn?!{G k4t^+;˒4KO1S(j/z*j%Zc >-IA4hg|gjXMpVZWp]il=S)ublF0RPosp\{9d 0t}ԮRIJ g2 3kB !1s0]yw ?qptDqe+7Ai]!.~M~LNCU@;'u%YBl)\"BCQвIE1b9vc!E4J9Z1΅^p}Sv#._!66ҝLt ]pB4K7:Y ʍU=w oh;*cI"dt Bu]@S J:ݤ=*mpY@c<*D8(NXP.H*ڰ9./=Տ~p懩JuI>YWݍr$17GkOyr 3s=rlxn)hq+c5-]ħ *@$=VA+&EH7kQʶF̱Ey4Eo0Ɋ|EZH\s|&QC?b TenUIw9֌iqVnZ G-$0b;NytgU^#u~@@ d0c?d@y>bڀz2/p ?X@> =:!#u>D#- Ƹ0t Z l ݷN6"1$FrR>ȢP9UZZ5b0FCނ͈trSaʙ ys,SR ;IuX=էE<9^~3Q,TG$ (}Z ~:*/PS]tMޖsӘ%U!S?J>xzH5]R.!_#Po_5]ZtQB*"ހ5ќ.|с=a'I`xJ/kmnQYèL,^ű G>fvmPօ0ᷚFW@D@-TBq<EokIeh`Oy.A.q,T"}2O3KˮO0CS'u"y? 7E8UX'8[ZUٮڱlN88"-.Ox]v"c]ԃAS5vzm%h>-ܧǕ#ߙH;jj4m?7ak*R8zDs)MXd`ÏIX3aJq5M%Mlh{VM2]hՆ4bcWB_gH'M+յn[8"mcDFw0ӾmiXT^JpTM8(eG,洚9Ā,GwbkBڦ?UY W* |^}EϿܹ1d})`t>t6DoѺ1wIO+l߇<۸ӯ;FBٻnm c81=q#~{lGhY:PB2~fm/ZCDYQ#eBgGgpѶfaG0¸N=t?ŒmW\޸Ņ*;fR a^Gûb$k W*ci{,pR6 5p`wl9bn{έ`Zj,mT-$|$bYJ0)#&Y8#U^6#@ jE %_r;U=Jy,7MJ9㪢tZPL(7e&`ՈU-˼cyݳJ\de\©Z.L2ͦ%tImz_h",G$W,F_ftalQE-T*`^E]2qs&AYd\#Y"Sd*K6' yJÚ!d"y]W;&?R5F^0|.ZFvHt/W0%녊K>gUhCM@k.KSrL$T45Ggc?H~nd_8Obrs3Aq;kOx#4CVb4hpB-K0ْ&G뿇_ptsvyȝ76ic~[ļ1$ؽN pfff֛{Ф*SBßx%.ic |s-my Χgɉ@O]p]— |7~opVmSίrB6eY.r$ڪKc{F0T2w|t?)[K*R>Fd5YPz}18k~0j&8''p!mcQBQ V҃j׭u# SM+Rc*A䥪k>3Y]Ȃ Vzv~W^/Xm9'6ɨZ*kѸ;Q)*jr%!`Hؿ½.Q/ \&k0Ή[d=Ĉ<צෝG)Ӗ` aLsG֞O]b ȥ?ҍ\Uze$Ps<ak:zrGSw v:8aW8Z#_jss+A'm~SqiFu<;}՝lȇá͏?Ԗ'~ÀggܭFe=DnywwK^r@oz#V1\RT9SW)azwg]ЕGPY6B_N:pIRv~a_p]ص?PP7]K(H3썋ȣahU|%:=R/>٪®Gtzr͇]((Xyo0Ѣ3ʾ)F[#j'7pfHeUZP|!XT(2Z">hz{oⳲ}'uM%a ɭ_z'}p_,nĚҏ>it/n7u%ziz(>4?b|ӏ+RgU(\] V]s6}>&Cfۇnc  8nʠؚDҾ53I~{{b8F ͎s msr`n1y>(ixÇo[ U6(.7pSdsh%6V ٪[FTLAq`/eMRAdp)5W1J>܀18) 2Y5&+6W S&slhXMVfc$Jjq=D3i^c¢YDɕ> ``y,p˔ˬ-yi&Ls%X<o7$~ @ަk7&Q( x2Z$v>vt|`& 5I@]F>xLAJIbՌs0.ini b #!%SVi`b="7NdVq&L?3xKd6ܑ d6iLl'0%ם-cxx~6;Dģpd`[n|r0mB~Y]scCo$ia< gw# ab[%"t]a=}J%|RH"u1zf3RԆbïe5t$ȞtÒHfj}:'GChiBqhbڽpe{GYvwvG ODBdf&+yS3ೝdRm'W?01;9 h4(dU̐;؁;:[h ֜$'Lk3E}<*Pknٵi1Ď0ؘa&Q) Qב][lS}k6͟%+Ķ +;]sءX(D:!O55FPP^(fV퍢g;QxkccKV6܈ؔRVx'6fA?5WU] ==}[ݲUZ(ҳ+YUTz'wBƕgYA߈N˲WmlL dްHiu34 i4{Pj#JvCӰ{n_ &~dٻ{Ǝb,n / U0 R+5L;ꮲ_քv2Su {-Ǽg{6JPeuqN&xRڜ@o!GAA%[~<=<=OvX{~*^B"VR{=SX.+ѠD'P VAuwM_J6H^5YY }_Qha muޠNt!ld4;BE^ayEv4FL} W@)UE$iʹN:@4>pEqP}n>[jrx _R+?Ɣ-qJGAc[g4\pծ)<䙼tZu *)iT8?޳tNk'9 ⟷0vݍ"6y Ic_#m3 ?fڭӫHTUKo6W nd/%& K.I%Q脀$$-;C+wsn1p|uyO>oo3_oA]<Sl! M0t3# ;m #Etݫpۈ ; n, Bohmni:ܹj^ii@otnM;l6YnSFpQ%&}v{h s`ؾ]އuCmރOF63Mh}lC>jߒ0A'TP fpPE;ԉ8pLx ‹RyQKu穰S0y! T}㵬D opC^7R,uYpUVZykSxSZcZr VR HViU˦"ĀRиSYx[Xq/+RMB:EJk&țIX7r]+*/Xb4%+KL-d/9Jen!$uFE5}!$ -3Pk d6 `+ RH"jJ h.(wBQ\މP*(!fQ09\Q2DٚK٬/1%tdp)t/9K7Ʀ`Ȋy3PE)9t/O:=C&٧bAmY=gzA+ i?քS t4...0vk0.xXU +h{kv[/~vαaH9lB՚Ëg|]|4̢3m [A7w$AqˉA,6:k <^{3avo:7Ms8ga)T6]{2^"l2!=X % &S{gT*pV~uK&rz }~?~'Xxt )<@ N12m{5 pF\e]&,Ѻ޿R\+~GK"<ؾ]KT:-\1l#N!1@8ɏ3 8Y0  \꯳5 J7KA˹0k%D m1!, 4(1T`1RU[࿯Zb\g Q-2@ !RFf?3AaUĿl&Ja\ͥ" k)Xj-5WLx1EX %+oT\[0ȔJNB(D$Yf<F_8bjV9 BVl;ؤV|E]ϴ6RvՃȹɸZD` \j-2\zmt2.ײ"Rm H`丯#;4cXLBX*t@Fh~T'I;5 vͿ,uX xAq|}rtdr_tm/6|Ɨi<3z2x%ݹ|XXiBɇN\ٵ~/&&0DY1Qq~ I%mAEg7sf($ȥ=7&b9_p3xio2 | }ED@ޑcD8ZܤESFTÃ~ONݴΧ\ l)SLG'h6X,U=WҿWgQGWtAđ ;w=!& V#N:4to9z9W-bp& 0 KҷЋ@)XZ0 -x]fsta$Z&z`x>˼,R[wES$ݹo7#WMR9'^4noz]%zsfq4ڝfKn;uݾj ٩Z.Xquw޺yW$@Umh - ^fq _gVrhnpvk5Ҿo sE[Uujw4=T#3XWEm7-|hc nMr^kvH~-`w5 wκV$noϩ}ֶm,^`n ?u[Z^ӹkZ7ry R,{hv*ځH>]6z-IUG+tj  =YMr|wz׼iP{"On"@TwTkt >&|H.Hshn5[ۼU*:yu~=dN.~Y~ұnEG{L*5uj d- X:F141L5Y] 1ݝ.jš11\+k5LOC BzU-C1`, ivgX3l'd-mXX| 8BcTBo[ Co}ua( Zؓ D?FoO#ߛ9LQWW-P_L}6H$ۅ0\!~]f2 , !l[OXᲄ@☘).[.cdl)0^d_Vp[Ck|̙kh̜]%JwL``LkFvK?`ubR@L B3}cGZ84UL| ԩw WRzpP$GAgv/aۃQTc\5/06tP.deXC A8Bo!%淥eA{)^,Њsgq0oT>~ǃOvl|uewz wι?8ݻ·cuZЌ_`a[ 7o`V9/AhMi;-}֒,8ߌywg;msie:/v}y]Z}|dzޝsP~tWvo}чAXy>*>Ug2싇=y/;#`޽YMIs]nGgm{W ;pns=_nn6aymOVoz;׿>~pETv~d]N۝x/|VSY1whtS"O:'#t;XXB;t5yWX#CQa o5`YSVa1tMV=ּj$_z'%:" t|/##dUz6cZ7".!bHE^[aL5̲dYyeOnF<<'"s*Bj)-Ç[Y5sRbr^`Ww_e'E1Jܢu|(N)hCZ4mO\h:`âep<.ŤIcf3"Z+7ͅ Iۥ5dyBX9d%slCR},Dѷ\p@ihOfW59ܶ;SnG wVhyvʅqaaQǜ)65@P㦯ఊ*^Zd WV!"`*XVb2REq`oO&_QўgeXes|s\n>CQO9Ui_E˯$"CÅ6p70Yں5e\%hLˁl "Za 60py~5Ѕ"4=<g-.yLi /ِ.ȿf!W&Z(W Q\;W8|0CsL=H i6qdvjl&fJK[J*ib86*j8D,#BLvɒYե +Iʷ*-Y*)L3"Í :lP>pibYl g۔7 D=m% u4椐%kQ#!@t$n6uaSp][!Rm /[x$UP6VYo6ӊ%f\1Rn,UWhKqѐ\:IA ˥s:(:U@𐮟ynRIjTFC#([xp )/7N Ϲ"휢|ARgbL"M̨$:ESnĭ(3i UeЇL$]djlT(&t沥DjFS2mj\h%䕆iFh,g|&~s')R*!u~wǨBMC_H[b"nR;nr pypCfT@+KSZ`ގ6^.(urFV7D ]U"YcX$a,/,]X.'3u|f.kLKO9zR6Mij{#_c:\adV R}Uk15ȁoF YV"Q 3l!shJHO>DbSP5m'DIU"vҦq"HdBV~2.TG/Z9)\4R &}H*_53 0yQ ?è(3i5H`<c Km ߺ`^d!\,+`(\O5'&Չ,OMmɻ֠Vkqg+DpMKz1+mvCGo}-1׈:_f3snwJF?+2z9Arf*/XY'beY&+$XZlM s#Vt?Kq:WW"1%jg;7)1Khvd% ^{چOE1Q=-\ ^-BJ)3Z# 9͓p&4xMPKp|%dXǵf26k &h4 vDf1xz!;[KI];hDf6 Pϕ?GCޕR"Tm#c>hmd)WDyzk̍|CN$څӪ®7L Y,2|xϾm`W H>+ ҃)ɔ៓C-nV 3/R6ز\ NO5ާq).41 e%UA'!c&5U,=G~8)UU2-̣#OJ6hZLVΛ,7R6Vݮ~gH()BW1Pz8ѤLLݥw hBKQ+ߵ|luϲD-}EGFSK)GW9}m Vk륪D=drW: quT9/r4"d-^jd ~iE( qHp%Sf`n/'(.+}7$hr|mmu+"c{-pˇ)&|Uj|f]7-U^BȢ6_=Z6CW0"^oJYUn}=]) k$H_Aq3qEg[#ʉ aq|)]D7`üpgAWexe6y`o2%zX<9xnZiSQrϰ(>[>,'}: `:m@"i"W1*/B`WN9Šgf램J{ eE.}F}ֿB#O/ rS9'a ~bWL W X+ J_hԿ1'`_Te0 kf嘼މB%IȲ[.a(L`'Ңp{.<)qۙH7c_-՞v Jl8[=lmߛ#Z`IRdmaqlJ'\Wj5*Hljc奼gȦʢr~4݁Pk.XD)%щssHLtC|Wd,l9\8;9%L,s^ ŷ-E􀂸:Ud.t{;]d!eq~X#zxz`20dt$HAyŸܝ攷T8ҟF|^cNH]ZXwi'0cP91gHa*<雞q،#8x `cj)|rSn0dPHR7CKڥ%%"Ieȿ(ى.+‘||);;'&,^a8ʮ.4nGAN ~xHtB@em64 (wgN!(Yk*ȴ6 5#}Y~TdA. SG VL[nIOzX.ݎ-wL8WF)n,IRBzFp'C1{ĤDq6pHh~6:5omy |AmHXJ$<<*bG]ogHȀlV)ˎy | pHy財*~)Sj%5RC^bӧjRбa͵cZC}/4H ԟB3dJt:Nʋ‘cϤtN9eEHR$km[l J50Q2.Ǎc |M*RF1|*8i.p*ⓊߙUZ٤j1_[@,'O{#t)؈}dM -#Ï Y@$P.O>5ĤPyCR"k1::GS\ގ?LOM>Mo/'MXAU"Wd뉓V+ ~8qMkFr4dCf,]fU'ch؀C3G!9 x  рN!)bXFh>a;$\D |N$O0G[BWP Zh*KmZLIeKαUrDn'$- }04 "xí=ols{y5KBt pÒSd`C$$(LlLnb szur5pn * ƍJ /# [g$=v<ͮt50_Ny}(} a/>M'Zإ퍉ak_*sDˋŧT;{fywz^p<7lzak\M15 8`l%ԪPغeW lfB 6ʉ6 :xyd]ȋqYTr)5d6aswש(4P*%ɣҘ4s uFkͿ2+-L{t:Z]mfL>1@EA)W =.96wpR&C.oJu|g*B-E8uÂSQS]JV&jDCeXgG ҕ9tґjcf{flS7*c"МUF!]"{IRu=!j: ~är_}Z<ࢾJV2"=,:]wZfLݵH s?j"UǾu~+OӆiPT ThCIwP4!k3zJ^b1'qzOSdYbQ,MҮ_'U/JJXI)2bّtDlG~JHҸ0no,|E J8֙5ReҖsLC+{0mGڂK2' hE^?eO WYb~yk /2N$<}~i .' h #@__|kk/H׈rH/ʱo>¾ˮ`j!GSTx/(RpjW-g`JM #6؍b^3b(0nu8%^VQwR'B/R]k0}ϯSle;iHmVƠK7ث#] ~Wvۤ c}st{ q.li@50* ֓e@n̾=d<aS[S|mFkA,:GBL V9X DiehǯFJ`C4_DI>iJ1cn܊$I۶qVI9OU+ j Qa{kN+ƁtN%WL 0R(}L^{ +O&+UHmsIL㗂tUC㿊'WMo6W HKl7i6]^.GJ$")TloCReYz- [$nQ6;"cd޵Jsp4(zy8[SSiвUL𱔯T C?=h8˨@3SR멐ȁ \ߟC8qfDIi@mr${)K`S1CGRTR%P*D) 4DٮggL CEH`LsK'ux?f:'cCQt;ZC֦, RQWsC|3[L\+} |>rbjMJnxrzrF2\.K64}a& bw}j<>|m9Awj9>Wɇ(:Eo[A|Acz9}GU畨 9r "9kuf{XE- &@:/(Eƹ?LuojLHJr'.$ϝJ`dUA-;:$ NwycܼH]4 l91CMɲiL6yDK|GY*,%r;۔b6gQ sJ4_1Y=\%$K$Ҙ!S(ȷ[|>$+,MQ9e"jx4+YD-`Ia*ܼN 4JB99TQgt!JgfE2T#’,L|4C@pğ\7%zCwHo3`p<d03:|o<N#3%}2j: g8\B$ # ,X@drA^so\ / 琩3 șl:96ܡ :\~t/ȴnzqI`Л-:od<|p?`3`}?@6t`^ g |LnDGo}2g׾k|~{u8pgiM=r9pCɘ 3".En.][bPTg`K\ Ѝ=X4GOHqnlr2L<lU%q=T^C( e}LϠy|}ْ.|~I#p겸G>#X6y^M(>ǁp1WԜSg{ϐEqt[^Z~Z|}.eF9 RU 40> 䬲#IWavAKEv;cs jzMxdImcIWH҂6QH7;Ѩ S3M~=Nrl!ޓ]e̒ʍX-+%Ydg K2A5BR3ɸADVKۻ>`0SB1'Ĥg8w/{[7%YKZ`ÎUfh(IMm?uru / I\;m)VTf>OD-NS-VGK!i=:aqВ <*O|SYigY ]AAEܝUVrW*,68R[Idoy!&d Y>)Z|CН"ܙ&WQuyEj\{%H0#'7Eo=sM$f$iukElk#Ig hqmhYIv&6Q8xb#?zKNh(B ߡ51/ %^>2k|4OxA񓢀h9b\- s*cIݣLNV%kcIvD<3r?]>*JKE?ϹDX3Gd8Ga-3x4MkUT< <Y ).8Gt^ZmAn 2s}nGi/L]ުT orG?,BNmU]2]8exPFg3 vWhey~Sa Cu)Q"C?9TJq~R׫)X4oZM!L{X߂T0x}AKiKT=eh67ͫ YCAAov7mۺkx5rW@^{/p!ch⁕ђ [h cx%= S#rM‚$8C"/n7kSmY82ig k^:pJ:{l1M?v}}Ž5bzfkZ[I`OL_c)FDUA.מڔ];8+_9i`[ p߂NqRy$4 RguLQm֎YB ]DC /".KVKTW!,Mi.G\H*BL4l_ɽ -l"3vV]#s'j{R ۞!LI~<=3r`HqH΅qC Xdݎ8"u103m b/9d+މY l#6h 6-^t`]fIaXꫧ+'Fg+!_Q2> j|ԉ/a>-?b[ꔿ-Ad9y4xo 3nsrYfs2sJث1Y#ZS'5Qk _Y@Y/\t:G#fPIvfmu>iq΍OTC$%tύ6iB}x[G͡,ܵե-KA'q9kXMRv!俲W&T8}K&a92'a PYI-d1SS[P&=-V^Z×|Խ{?o0wn}3ux3j1nҴvnO꾅`0oL\ھs8$fZMM GiŻWbuAsoCҦPhф ƿQbi('ȕP{ _mLvmoNشj{4Ŝ)lI@kM(*k!򱌔Ûĩ˞QPrl}} ֵn?~0i/pobz[7?ޝqx;pI\WXwV\pz~o፟FFc zlwvH_VY[SJ~W̒ L탰ج$CmHShݣ,2sps_2䋥"' HqӨO$!z:#eLpb)\q$)D.#GBR!s!WYr$BEPJ|#2zJFLR,&k)^x jIb 'I+O$iqS$fDKdY( ('G<# Cui܂@@aX‚*8 J2V"W,UtWIVT1imIPf܄qtV=R>*+,K qBfzCBFF @Y Ha ! &P#sc"dka\>'1"Xۃ;'&x=7厜~I O{{xx>'#{= bm69{D?<#y'=Nȝ iB`Ӫww w|ݱ Gm.bǙzO(;=Wf#>7 @% K&ؽu&CLQУ;'<Vjgvt`isO{7rY&|4};>ףP#p땅\YX"5Nٕi"Oc+L!"NY R%Fk%(<,fv' gfI9j7 K,-DX-dX>WӊXR*Zzh$/'l*Jä @5:7\سX ~L72֑~+&|q&o%TI yYa"b*eNٜ2:$MKک,+f4em; 8 Z0b-YYX )|>s VT.xZ x4ꪝ%2(5?AhDM(1ݴv΢4LjT:Ȧ4Օc (f]QO˵ZAYbT"@APKs30euBCi0w3wt:+GW ؉5߷f%~ՔB){E5=}E4y*BwZ}8]L(tW2O'P)k%\AKoF! ]'ۣ[x}bNCk-ڂ_ukpD޺-ZSL{Z hv|WtavwY\<-k#kq:ط,wGn.U={USdd\h_\zeQBU+j ]>sU%?6 c5Z%ǻ,|\OO ?W-)o[KJj;JQ;N-I4 YP4% RtHȆ>4}v䦢{N$Y^ 0|#>v|v!-1Z Un5Ef.Mq5~V;$nۍm~*Ai7=$ڻ69MLZ9c%svf{+&$9Íe!-m<),5:iʛu@TFy70U+kY{m-sd㽃0*;ˎEGuFjZsY\5q+nW8?=]_Grڬ0/EBQe^gEYS4s 3+/'yt`GyBe1(V4۸&"GRGο_Z2Ph1GbmG,uj}{7!S4%2e,ɱfIr|Y7 I=dM~Uxdw, BP(t%IʪFUgo$&;.K6y~9!t4H'IAJRܐa2iN ϦIϋ_γiZ%yq]۬JBE6JF?I HqU'"KuV8I~M/Q>gبF^p?2/f|LyYQU #<"3ͫlDP! B zM0"СP;r Y ĩ5VG09uZ"K'b<0(D: qG$4&.A\1ȪQ6e򢄮sD +AQT$; c0CQ- P )`w۳'{ ݽ͏Pdwgɻݽdhpv|r`} D˶~L$?|𠃓~saw~03r5ώker69;yn??vv{{|'g;ON%Hp~Βw& ҠLaw3$KL$>|O~s{ԃdwp{['{;Ûӳg{ǻ{'?n&ǧqN(2g۴{Gg{''ޟ`?mhKy}|43;>A#?h rmq 9ӫALJ79`=p>`O>$@iAp5ŹOG7l \|(vqx,tvo2_'?礸e7i|$][/8{ a~]靓 !)1\ Ǔ~OCi5/7U݌$*$?O'$eI"'>u%$"ڇN~OvCǸj/9Oht3-sڔWyQ+oV$FZYOُ -_RdVq]2eErJﰞ"c!t_)&%&yQC:o|ףdmƇ*d.kJ`o|B)Q1Ji:y6` %7fv{T8XFW"N7ޭͰ2 (OauzZ=*8ɺ%(e] \??(^PIW*Щk#8O/+rp~qt7yIeUv?k.7jhl*YO ('(vKI E)'9 l0-d6kfS _0˥4dk Sx1f;ނk& G;2RPDC c̲hN;j[PI$Hqg0. !Z ѳtD Rr;r`AgӬj}<a'yh.m AiV3omAן<6~7;=C'ߍbhm< oXAoza !GGQ;C#8~y8d+Ml`"JQ p ?tQ'* VK=z JRP((O9]ܻl2,WCW7 S7L 6ɯ`Jf ת]ٲ(l%QaAAL0?hl@ӸV{R|dڴ%%H/Yh8fFA0w 4x6ArttkwPn8/L@y8>R~jV KYE.1K,d-+ңj:A1 X:S(ƴ1"g`m?nWKSO+ŀ~s`RfTȫJxEU){B.̠%{?i>8BOG !RӅCMBF9imC>E2>iեۦ&n Z3K.m-k'Yx)l:"{|te&*'ru 4yrWbR n<-.ӶDe6͓ %hHߝIޝ1r:xg؄5ͦTA uNO 0pϜő-dQְ 0s*-IrLXMHk?E-J씅cfvq\dP]dܤ6WbҙO"q(c::I_#VGlߕ~4Ax/?T-E"mj{MԄB"+jק)xKu6OjaN҅ i(_)V5 ϲki79p[_*]#dRZ/ح,HOO߳L/ALnv`>7(]wIh@k0a3hOh瀂O6zvuQF֬b4QԨĪB%$1>Uɀ. =oiaV4gj?儞wt&} ٨DU* |~=iIoRUQrJwO ϊUXSڵ|\4ʽX pY;,֞s;3i +k /%7rOɩXaɖj~(Ke=|] i'ѻxam͒u@ЯtwU[˱!K;;g؈-:L@66w9L$ϋ. Ҩv=h<AY̯S$T+كǤ\'/l諃ѕmǍ+SXat: 'Uj?0WlCkgUDk~<%-ϻ ;㦶OXhݿtDgZ\!9`a腋72J٥Wf+iį|00 jȝ e 3`'Hr?H?=˅v{FS8vHͶ|LKm"$}m_?@>XlmTMz7NM4n XnO[xslV^)Qχŵ]Ž)yAT 1.XDzRc]·H~_eN N+;.\eD4b |>xNl>%Bfqh Jotuׂ wz/ I5&lڵ"s"Nz҈TsVI*Ez_v7PB[_ Fn AKo}5Vh͡=$?.LbkX"?yOf.'"WN5{Șu›o˭(ބ\ $C*"Ia7w@0$eªNt4B;5s<%}=t9w LږD_y+o팳2yo-w 88$U>.y ұWϞrp҃({j8 >!b,*nn(Wل$%d6\Pٜsk33h]&mmܲ-~"O^$wV:4mKGP01bҭ䜀&, 0`}J9w=tdT k D4W\(>wl?z /8pl7rbgfkt6CGeyd_k51k[oA-~~w W`[-Ku]L=WX )vdA.6ہK" R#,jG՚hlA .sE Q,+mHV~O{COzlu!:$Wan>=o[My&B(\ Nr(9e%.e"V#h*^.ʝStjOrSHďY+eވaҟATVwx[4~N"cl6" tZLj6q(n҂a;JZ63Ó~ĿV6 ݍ&M66O6Y 6?: +vQ`io{33& {(YăKBb^oNϒw{/uN>ρAGO3W(J/%;+R^L8СB(n\_ȼ=W~T~Rk8QvA啮=\/d1\lil*.$8 ]^TM!A&dhnpcT1HqwB]\-&{aQgۼ҂{ kE,߿8"xQ*~1$,Qo7+ǎ-u<V1=}ArT!q;D_VӸἜ^ՎɾUʣY I"Uʙ'TC"Jz`lDQjt?$F=Ь?dev>!'>2^[) Pj8V4êމjף#cosBlbN@K:o*N IS_u+m+[ަ~Ef2\Fm@ux{N+#35͊5;؄aÇY T<i8k?t3Ƀ4FCj_'ЬƩ.o%NUK6ujg)a@]3\IgkW8ðJ 4z7K'N=@NEuL1y6L.>fumasmb r,7 j[ ==dڭv{+[$:1-|^//Zt4e/q܏mx>ݯ1aOͣ&tY-84rׅ d*Ĉ GCQ5g,SU}^Ÿ f;oVW0Z˵M8C V^ /I"QfBޭ yT< :Sw -3Xy<ӗ=q~;§p] 5r٘&E-lڠQ&R=UokyZt8JBe0:AsC"}U@++oo@Fy׸@{T)'[R0VsuqѵWWB¬`9sЌMB R4μo.Z`ǜ k7W /b ox&ɲ\_G2~}hda XZ†34[9[jbiڰknZuZ<ތ|1u_Ό^R{1[tӨ~ne/ۉs3-G>K.IE CW;f/b9f{h4dh +kW˴de/܈^a4J""gª1q0F;)Z,V33`зjRd|aCo~GkU{ŃR>eLaL/it}$(C5)#ܵś3¸DM_u.|t8>(X7gozErЉ83Ǐ]+c9Xa2!=0?Y4Gt@Nګs , xc :VcLO:|iBգڄu}ɋ1)U_d! mdS$xkRz{7k|P"r9Cc'ٍ\yIԪa:!,"xiqqߖS1;El̃m"Z zz'̯zU2ĻߑZ+>w'uD7iKt5DџfV}#v^Q* zqieh7=+?x` lȭVrt4B3L}Knuux=yl<|Fhiu|Ń%o!l(b`xo<D7l,+9kUrBB/j:TFC]y\vP4hl7 yܜ Glvԟ[BCi \~9$@C84 6 t\+0)mTSmCr+tHJ=7LJEڱB=XToԵQw}%wzVC}oҿ,(X2o4RTgkʐ x04%JS!|Bf8C u1;"fj#w]tRz6ޙCQŸȢoMxXwA\DnJZfέXi^d9WL`޲/7̵@hṈف2Bg q/N}L*[NHh'f^PU ?;3 ҕeZy=m[Xbmϝ XB_ِFS~N"tJnm0:ʈZWɻ]yC82o..j&Mj.ʃ'^Fp Uj^zװ.0 ayy*!F!}eHߌ5fY l]bCk{|okNbGט]΄+χ`HҞ1Sf̜(/aa"Youd,~qwE.5 o/Ɗ8y5^>E>k݄Bk &xUШLO~e)kSA=ϨsDgpqq]dL4;OÙeᆓ_e\K&vPa6Y\! ^7Ov!$Wŋ2i 59H\H^磐_tav&蝧m3!~q0g.ka|;vjܪ/ZnPY_Ad:IN7O򰉐~4+xYjY9Kecni~Lr/MojD{mGe➦l OkVn.W(7-t 8O0gEI|xGųgM[PNffl4SލN3T²^O㲭RlDY-t9;TUo|X!bnPY\6jݼZz8%k$ӹXF])!K~˶qo/w1kmxŒ9r.ʎ<ԟܰ{4Db-8*փBUL/S{}5<\$gbmt\*pR*"EO-|YOK#Q#I fxCAaSNUV TCa.PJuADEz fG|#rE=2'!0\WB fBO!vVV7Ձ! XD e7V߈˓w-;Eqz_%Cɝ0$XT_tÞzrv0D"A}lf ݪmm$ɣG @ CƤ8V _29ρ<{\7֚|^3ˌ_5s6F+عU"}꺿+DRa=Z=,ќ/ս 1[`}?tZǗovKOgeX3vV?W:A61ɴC~3vΚo=kA q,Y[?n={z5 >\/XMUM2~r9_eq`{n8,W"5RK V3jm@h3xP jm$V nQYZ.8ߝLdX>..'dT .weS`Y>gL_p5L~V2W>.idB#Y2]bva—4rfc Pzʺj!x{R :юWJ|n0?PG[V Æ'GqlixTzP/Z\{ i]Te-UKu/$z1A.Cζ?ek}>]n ӊC/4rT ^:_d%?S1.26 J1OP8\e鄳hgx F5W琘E|49wn*=|cu mZ^+& -?ּgT9/Z,- X9ꀰy]Mt/ 7<R`τL+ 8^ƒ%VF }vvg&4l NE2oYJ<F}';`|*d<77,M^abIȬ-/ vdgg'b  6/Z-9 EA6e]eysmӿw^哻K@o J:'OP#j _dɋ5mG^>Я:l#4?Ƽ$2:On|25ySRTK|:b6EvLrᬉdmA_g_oƹ.v"Ǖ~MolkOjQJ،u)`NGCA09+$EK`Vv1:4]Ս<3L^mtKؘv8ŀ% b3u*?\M^q(9Lu9Ųۣ&conXB` `t<)EobBAfE.P-^e.+ >ik#S&Nj@Ĉ/ꖐ)?5zמvkhyMߥ Sj(i= {Kg`TXVgx]~AQ@$RQcJ ^BeA7EKFYp wq#_-ʭ\vy}`BXU6\zj52ѭMM  fQ"<;ôr>tlFah~4`CXx02CfQ/yhYsa<ƤއAS6ܷiJïp'8q8:hJאXӎmvpByYzI 1sΑZJȀ=TB4 -zJX$\2[ 75׵h:8;Wli6_X ŮgZj},25yaNqVՓ}0,;]ꫩk߁ey eD"࣯ĻtاA9FK :473iLt~9)9OV{⹙ ;C'6x֥ntj933#|Bw j) Y3h9hIyk-tBoPO- 'R% {* zuPͮk  7z{̤v|ȸ4sENx -U;O}QQ4 lq,RhdS%`m`<监aڍVYx"VO\+r{ "!U;č~9гzګѽU'O<\eMh(Ŗ|BH]G,։7ΓKRGEM`ͥ`jwnkg\'mTK3"2G?Qh_{|x(Q`+q>:^C>`B7]krx18}*w?p |w4b-umK-Z6c-Ffk_=q}p[K[| 69gnpS36e5*Y{dnrID@R q /4}9Z<64rǾ ̷\O#^?>P H/7@ =JA4!z\原_"G>e]Lb56}mr%Dim+w7hWd7upߎ/Ps5jOw/[94g[@TRhkF4Wp}"c9&N\Oh9OtnyФ{wMu͐kKB|y}Y u6[}s#l5'uT~޴Z;^‹x|˫Җ|>i ]X>ԲC1QcS.˗GF`IRG߀HRƻ|^qkRT~tRsK`8W~ m<߻|7#5[^5 s4UW[VUST.ԣsF&zf4߭@?pGV 0~B\S'h#Cj]C૙ZE:K)M6T{4FQ1 ueѱu%٭WCS%{'i:;V8;&g\&wGwrg?dsW s5'`8_Pؽyںgwu_0(Xp/ \Byߌ[^Å[zly[V& )&H|ŻL:42Zh&Ƚ;ޔ`fܮ44gS'Vl&p^vВ] :v$;,)C>%+cۚFgFUl߆x>aE,6N<^t1/E E;"o^6'V[] =yD!1F%Ksr$| !W:i>mAfj` 5]M3 wQvuFЧ L邉hnL"_bLy5C3?N2[u$՛|>x3uh4H_iZsa>r`&X`z*3&w{a7׾KZc݇9K/8ʧ`)7A':HT}R#G.)xJ1Ii/* dDϦM%,#,# K3 ֞AS>ǎqa-ۂ֛ܤ_>HO9IoBE_oR{*+ /6}!JzCص?<;8K8bhDǏ 1xW*t̻9aNOtqeҐ o9[I3ťqaFR]-TPї[3j:6kuVeRricNZ޷U 7YR47F^) Fs#_GM7q;;ԇņ/CZ\:],OOz|mP觑g匌ۢ ^TzGal2JO`S2 !i>i&4MJLTӒKvr+b:Ψhnfh ^!sBCG>HIfctnCT$Lyp#xq,Xi{+pe0G|N5| 簲=kATm6$ϬU>{p2}z 𵌀W?[,vd/ z35yHB3ǿ|8:s"޺VqvŶM4>ɪ>mW䢇_QQ^~8!"e:!?ɠqNa)t7̮i$t3|`RLT T3~̍VU356`k(*<as"ìF>8LfsznO&- .Fd.kko@{s\<:z~ po\Z CelHVUx(t 2C[ėvgbX ($`j&+N /\Ojg_9jŠІ\ vW?\#<~)bsmZxP#]Muo^T FQQҫiۡ*0Z4almk_Ƕ; #2')3A9g)wah䏡#E#+@bcaރma/o:Z!p3ہ2? jqey%ɀDYl0G3`͘C3CMAXK:.[vWh~ Ej ]i½\G5VwQH=4*H`2Y'Cfa. d):$aElp j/1;%#hfiX>"ȸRyr"[*/r*(Dz6NwXPZ,a!<)a+Q^h@#PщY?(^9Jg6Rz#!I SXO׼סR"m|dwW$(qQȚ8Աu1T&~ȏr~]T c:d<ʫ>/|q*b ddfh #9tn?]7<Գ^ʐܷ_9g$..M_$k2U^kR:䊢}֟s7]VmoFίTN4)~0f6EU[ /^3k@Jg؝}fyv7s)6ϥZ5t'(Vtd .LTKjU CUlXE EU<*4&HԽaX J YfJk,T^ /8iZ<|q'UB2'd ^VAq zn+hX40A ZBUAx k; ƩP,#N1&u&[Lu RRLZΗ -K%jGh7@tC=>c3ށ=89(O⚣#&4ۻe0?~=w9B!v`{gEhbƽK P4#w/LF/pJqƭft;ow)E~թ-C'(ߔFR䱜YB~%S饈b'}.MU0U)A&X'blkP2N6Ox!*rݡg0:=phԎ?H ggWR7nP;NO-ؽ6f\fa<E,o'!-[ tGcݾL{+nQ%z=q;ֲIg_ 4l~9]iQ}L+cB;u:e*rs_zFyCzPhCzl8})ѧ<wJ ))rȉz?yF`Fua)O! 3=.9Yt*BxmDxUe}0VNsi/uT/i|k |;;ж8+o@gBXgw{m!q}T5lBaQ!ʚP9bӟDphX횽XՇֻj3r l}eYKvNf gW}+-d؂wp5F=0Ш鉠q,7ДE>Վ w+xOO*#8ThZ%v-99` !$:m<#|B\c6VCdB>`gH8 gr4ݕRk}.3C"M|/\x3.ϔ\Z[w ~C(2rJ,"OۓPf"^$+^7IMNsK`>` PI"΢8trq-ӂ.tcѺݛ؇ x83h8b!x p;.͓ |fC|gQ NOo; Uc[7~dAHA#*pܥ쐳5Wns`:%~c?E;.NxU+E,Xf2-((6Ȋ

y䕢Յt?qYF=fꚁ_uXLzCh5΀mؿu(ͦ 0g2Սh>hhyFt630P@a0 3 cKNg1w4ChZfI͓13#.ꃬcw:%W0mH}u} O Җ;I("BC{>]̸R Kj&<¯;)@/'1ua{^cDU. G b@4/NVsNȝ{\!޽ׯYg 6$s'bqHbq;EX'}#p017ŐGϹT;\4I'+uf{wPgJP{<7eB*QNriWnlu@ttu.Y[m'0oah9'[HB)〕U墂Kw Pdm45^mpQsc{$h{ T0iRD l- pZXX / !6B"0(Rٺ:\@.A_BvY RH޸ $Exy C}q ;c6S/1&!on/>=;KG,wEb=h]gG/,e-O$̗'Gn;htDHrG!7Q{$}{и ~[WCV)^QV9xf{q8MB#;UM|Z讠 K%N ' =<4sma؞Mm2 Q%9>/RiRk$Zh$%%PLK^%,iu6Q''MqJ2V7 j[THTJjk)sm-*oD_[e_v^1}뀋.B?Mv'\fDŽ$7&V' +`)5uBbܐD0ǶppCnDL 'Wb9ѾbGw(P=q<135/mk/T1T\fv_i)bIkxN4n){":kCZ]37 unlq2ԫFYVy.6/wYu$JEC餛ŚډA\B ޒdo+sd.Im/6W\kYlUbJ\zz;R%ѰRp~]S$Z~7h*cBk4 N) ZL?|^K;hIQ5JJ͙1=%+#psqJvA4P T{W$DŠV^Vr*/ܫm8Hml>~i:;QR\/ #෈SaVqPCVֈʍ: sx,'.eؔX!Aֆεl?D :9n0Op'5]cnuMEo켽P67'وjP- "T0DpXo[޹#rb!Tu?NWJߎn;WxoO0lu{ϼf>n=\[+wɑFGPgN /gQAA->"_#Ij.zyJ>g+t/kop'N |)=ƼG {I[lN*Ez焇;hE%>]t=렳OaǩSiҗ|-ccϵtf 0[Kq`>oOԊp=5zE,Bf[} x_׿` kBjm,-# -gWtW-I!=ڑaTj9O\[rjS1 P kXxǮ)pp򵧷>wǯLc- wVTJgha9Ny0ofc-CCxBGYG,NROqͧO&4?Z>OXBCPQYZh@g0-=J`ƒ`?ytxQ;O2,B?"ǩC#icglCX=2H~+%GCsP',H(@_A:gP,=Fؔ$ ؟wc/"a3VQl1&%<`-q<["# a)w(cIg<`?3-qGN3'NK~.x0e85Hѽ #L~fgb!bOX0SvM)%$xc)kg;s]~S2n/O/u3t ꧲]< M_J޺J?7FR!ʞk]arPDYbVe.0U ij¬Vn;t:;:^~_mM!q֥*`_} ?u(^Up)x.&K 4Y˾A݋jNK-Pu)9(l=xCGUZ|jN*6\>:gáYŹ̇ky6s~VSu`*~`u3GCT|6yFhľ Rm.6z(UhSjG2ЧV yVuXsfy%v~}H6$T@d2}N4ly߻{ҁp⾶}۽ݽ=)ۜA^dQP(7O5 .Au߾4] $c9d,g= uT#MͷE&'!~@nyi5xn BYa043`!l> qP0ĉ!JIRN(f9 j9S( 4+JO$iLC(-7 7 b?Z|G*"hgEr. ,4خYRաHq=_,x~`,Qwmڗލᘀc`L# 0&C<s̳~pQGQhЌ-?OuvOGB!cLeb(EOK0,"_ c(V6})ޜ,tp_lɓ,wX"!S0O<|5Q mL]al_2 t `vc _LkOtDK= 顔CEe#.,@6H{Ң Rn!,D\Z5k*GU)HexR,K3  #y) G P%aA.c1H5QpsPTާCn$LT.9+nXi \3*Ϝ-OިqMeB. xXMloBH1%wĭTy4r~ű< .3P?]]àK|?|^m?vr={ßهRpx@-픧Vl 6%y4q?g1M3+OzVIל1lNxjPU/-Bx \3 + {e2Iѕ^MaVc]ğcOPy3Q{ʔkzDZJ=M1§bOP *bid t -R4ZC&};C^G UUf$Vꊐ; qVǦ/t$CKS,klpcl>H'c7XnCFd Qm */N_~,맏z=<"wmCan 4Y>e>ʟz(#VG*D8rMnU {m|['H`HG6#T"!nϑFHZ-i4G+;dr`}QP4/v1ƿ-t(*yaP orl/(Q?6u#0;ub X 2|}16Ie)CrYwDQkɫ:Յ>o;QOҗۊ%TJӜ{g2y:~|X-}wb*K}gC~lm bQ\R++oՁ?[s ĽiVӿz_(T1+< $ qo5ms ~x77/M7.a b?)@6H֚ȇg-7g[*鿥pe"e EFlxpUli3 SL $`bn@1}EoAmv9s(Rx:{h}Uz{*b86@Z@a1jhcT_5$tJO{#~˝ >uv*9vhdJYOqknqv.PɧF4˒. Upeg5A22WQnA<[s:ꛛRSoBA[+C 7 sJ">[A՗%[vR`fHjR:JJJ^&;,_ldȣ KJEbՊ<2+ʀ"1IH_|PƗ!'QEJJcR1;;Â,l&dJŢZєohDpäcE*JƢ+Zo{o|9 4n `μJ#β,4N+Z|!䚥qvt{4p>&RY/^HqÑ Z󢢓Vvfh݋&N}%.(U:@>_^:TFSzMP b`XcA{$n%[ѬCaF0OyU ~9 xV8:NJ]~l ژB+8ɷ 0Icegyf)/$#~g , Jx~TАS}8Ӄ.֣@1yNpɒxAH4 T4=EKu@JAa7.Vt^̓[l`d{vyVE˒oĉ .Wt 6`(G@J!젫#^lZow&'`W6Pb<Ǵ"C87R@^Qx(z}B2Y0 N ~A) ʟK<XJ ʋ0bB0z/YLw!&5!SD`A^d<dd6`M/w(Չ$9 wHó\S21Y~a{'ts Ja/')1z=l7K&Χ Lvۭ-<+o\;ѻTV9# >a$@\%䔴iI3RgQOxrZ[2 dOI LiܐݩWAŧx=]^/-@IE^}#}ىC̄ ]J_RRT;ˬJh 9D2ˊ©J9BP$I\^R1Ȳ$XdW ~ l;x.۹>[;gVL9;qQgg7%a4E4EȻ😀0a0bj{ r YOjԴR Vb^V)yߌ5)H*X`YD!یyJEV`9\VP&8sPK'O@gR{ ?&0s(N2O] #U.֝HGB66oVLP*m2[hDې#s(Ȝ`DܒA"ϥv%vDa{tReQ_p:\Qlg'g͢0 {_r~?NضB]0Ÿp60lns*M+fIto{T: OdTy0f  ` GUPxf H3:sZT 4 ü0%'g5*}֙n/us8+ 1kS_ѵIb踌¼Rl-][j<3+]3aҘo7Ɨ-@`m L 5ϠܽF|`d)Il@GB"6Zc\BƢV4f!Z@]~K]_3HWkY(R}#p4-@-hCueK|gߎfAذj[7|ۢ#qE0¤}!ޡַ7/5NI39 nIT%C{ADGu]M D gիLw! 4nLKVX ݚV^\ '¬äa &g zca0pj &N tz/3 ">ϖ 1Ӆу]"[>a>mK;_l8F1a"T8d)^ Tue1͠Tf dYi&:} _ W-(fpXSɍ1JFƤ=Fu,hm~{ rmgF١ǂM/~veM>YA!pH ah;ksIJ`t6iu!C3VwOF҉JLu>zs`.O_̠{jٮ[yoh%ޘZ*x7jǫ XCm\!5j5ŰJ]z !YZ҂?I'js]6~䅱G (w{nr1[kU4[jįS\aƫK2\,cdؑC%%kDw\Xo )^S F+Qndsr=_AT9+J`)ou}ؒrNml .*Kݞng"KZrkdX&Bvv ?0OC6E^Tgֿ`|qflRmÖ}=튤-;Ip5NUl4N=t1\s>m / Zݢz_2)#$*NNCJ̺Ö Zvn뵙ؼdòg^hjkMۚjcbs'e߭݊633.fOrz$͞lgp5j:dlՉCGhRāmayxBu}1`)jڪT˪U|0rҀ޲k<8qY О@KBӢ`V球5_:[]_łpȡ/Ϲu-,wP*=*@c`#C`Y}BsNe< ә|xÌv!OSnԾ$/c&EќuWj?_'Yq;VEGfLaC"+ B3`3)gEw* Lavf54.20.4encoder=Lavf54.20.4 YL3h.Z?( ϯ/\ž1t9*Qs5D i,14dlkH흃<̽Mqt\#q$}tEC JR7REV|]n$kTi< VGy)zXDm|v}pnk i KS20kr8cBs$ԥ05LbVff,ײݾe jKEjr,ɭF}ՊQ,𭔭,& [͐[ Vpr+I#g<1 (S^z@$E?sЎ_{<=WA Hi\޿SJ"3X7xFxX?/m[ݴrq ,v$z7 PZz6]):Ư&أE,N7fQ:bۤV`?baGDa#@iSάAӛ]T,(Ym*Re3LZ|1nȉmCDiNFۜq#-]M) XIHa&rX[PbQϕ2,rW dC-UkD~VT-K5$",8p6\W)axfVsDqǰH?pI=gΉ30*j\MRtn*_sheB賺*C =v9y[m1 r3C,]\)EW^gf*w4[Y#6U.rkM)C -3e/Ego2W2Q)0KufqRzgJ"N\xnyj& iJm][tz F *%f & M%Y3(`!!0P""z;<#l?&t|[fK@L|!r|<@3x0y-c2$#{DFD'8.yeS ޔĕbϽ5%K#ܹ̄Q|q[>2IQfO)_[ɣlCj?ī|y@2kP>".'Yoۡ ]iOJv>h::q8e1T$48ЫȰwɞ|\2I٤N!yyD1 ~Mq N̖C$2yюf=:a[H;dI1&28ʁ"!%/<$˞YT71%XMmn|ŒHwB>j#ܕYBJ7*ycfia^ETu~hFI%mw:Eå4y4mq:-G7GA9>6fT bY #?_״_q-;t51P=)_R C"ҭR@GC.'B*)4hU]dX\DP>.C $l'd 3K|)b b;y4ȈHhixKQL1՛^UrFt&+y! 5GnJˡ%G•<1^N40/"ɪOnoK%q$*!Zn%OҊѶ!%_;@qRD5d(d^Ӈ}eyҕ 2!|-*u-4 t?bq(b;HQLmAƅX؆EE L0ɒLf%<A"B|.C<>D|TFyV }cjwڠ:"0zRD7Z0\N LUw0D :)wc>8'-#T)ݍ./h)ZB|,|S?3$[KSxhSfB7 @Z=6̆ĭTHLNo ` YtISR8AuL|K)A.B0iI?LZ)d&@HOl+ h(YZ>as TP"cD](\hBE"QCi RHǫ##N(@L2$H/'yʷb\6̐zZ}:̺kRv{jPsBVR]\rȬ. Y67|^lڑSX(p[ܷrDӕ]#R m[R_?]bYEsH]x 9E)7vi:kO&ZRk~y u t!8QOhDP0@!f-z1I'F'`oENq,jr2,o*2#܁R8#Rϥ:4;PFǟ}Y"d %-0݊N5ӪT"Mp8L$M{H>0*rE.QjLa/r>Ô<ߕI $r;m,VFy+ؠ1\3q ep~ī8U'9˪~>%/}]F8WTShX㈽)ÚLPSI]qSyn% ecw!!A'(ֲ'ʖ?UQ䚸Z|ܽ\{`nKT1ѡszN I(@ -ۀNwDA_RRkIҨRT%W/f$^@tqϠ(rR=0FCjջBܼ<357g"~ ;Wyֺz<ݺd5h5NT+Kǎp,dR}u,g>q4Gfd7ޔ(0'O4(קwX=۫$5Ssn_ |}<І]Gƹ %\3!%x35WX\I !5r.bՠ/4X `Lyȯx eԒpRN< TxQbQN\2n((-MD%#rLQ t"_ͲxrM.|SstYO诟^ﲩWHl(.u+J:)ݫسvgV}Z[w2akƳRs7Н A}JVXh'؄`F\8(:J eGE65>ݮ˲m ,GDX^d^_qBCvUEdzStj;ihIN.=Qgnt\HI +Rl=u~#vg:1C[OLEO}bvZX%gYMH$Ԁ4=4UBOf$eZm)P_ - g˺(f[X^`֛~`Mzmsvg4 `cbf'L"zupT[b(ZQ@_Q9I[#dyWi6 U dڜT:W[;ݹwml-C2Q% z/U8uߒ"^=kZCJ\OksbYxdKKj|t̔}nێzGI)NZ*MתƳ.yDѥޛ &ﻗԇ,!0=Ny%ٗJ6T:Rv:X_/]'1L zujqRY[Yso¦ܬGWzHd@'yG`^AȞp+@GݬzB9[f~ W[!!dƷkgҹ)('ppI26Z eʤ(F p #at)Di [%yUz^Iy{l#T6WIdgSLcv6X?-_~uj^;NrHQŚy,]RJB/׋Ulҧy^kWQU9/TzC~5!W ȼUǥ#@rU0L% Q eУ'ߪE\ 4+x-c  P x 2rYEj9IN6GX`k g^)] 9ApX7=_ar^1|A(7fJ.Oɧﶆ,]iC~P _r#n̛QMJ7*EnѩlNZ7+ gKpݸ0LDˇ"gܛ6n ! f^F0 gP,q9LN1g(KY.ǝ%];ʼBF ˤi:֊ &Xq2C$2< $ $ $ $ $4(;MĚ̍.j*5l[>lqLpEiSt8'Ag5;E{9*Muأ"!⻒mՍ/D}ϒBDsjcUU+* fbCh|Z|j&eIݱ?UskxAuR />zj^Zc~ށto'/.G1uuvNR{ qj#y;ߝ).|D}L99֢G- 5^-HvyuvCb٬F*,-/ՀM˭},O 'ȭ酫5 Эo+%jfK~kޠ) DR,rSB}*>ّunYⒼX@V% BOu;M̦/jS,Z<ۭF֣M 2`э) -+ߴfMioW"0UT{Z8?GD"rP(閧dn=cW AݶU7}Zs%j-~M].=c?[KtX2JRRLSʉ4c:l'GNO_j=!*F|O;i|\ Fߐ$ w.|r-* rB^1{ؔb$]LR@HGItoL7J^G񳬱WW?낂;{Jޣh_32yD/g{Tn17C10,\H`fn\n\>s-nWű%E7JۂW]QM7\Quاa2}^UtaHMLRݡT]=K 5yk)WDyf"nL\&ǝ;EO*`G:f\n8k1&XX8wlBB-0 " kC-hD >b[/0n!"}fAYm 2PQ5BxrLYlC'Q)90S2Ɠ8L<0r%h.? {mC]:ߢow-/MrojrR7E7ȹMz+uRR4LpQ2!r;J<[R\TKuq[+eշ:V?~_\"M^ZZT<$&CؖQ}y/sc#Ն͆4+#ʬ^/:h |+(L XwOic7'> [pQQxňPHD@ V;-p LL_[r h*g,mJn[BAD@ 1ga5}\%]:ߢogr&* %+qN[DH~\U!#)*9[,Ĝ%)×TKuo3b}UXRo'ۤKLTZD{ԵJ_$9h-#OyXl3@)+#ʬޑ!95JKU!TcەiI2߆ uv|Yh"0- (MoD@ V;-p LBHܑ SDQS=cl9 7-f j䘴 *Cؒ\%,pHr0I20Me@Y' #y7$&M_{>UYl@J!K HZ{*gREVuɓWKBl0fIP1P2,bNx&ʒlDչֿL;3=Vi \A XQ\U\ͤE ];ጽ4sʓK?. dG D1ăZRYJuH_M]OJP0"=AtƖ2-XtF=-$7i _ sNrp9OǞԚ"}g7јqg =E̠!$,PhC>]}YG<}eRrvgѓDAۓ/ i[kV,/S렿{kdqBX&ˢ|t"ΉGW7''&&ʞ2B=. &R+BYNzjNGtl,l@1|B$(`pURcy[{do)Ͷ?@V~6 LvsVG裖X_"L0bI ̓/aET9ƛV]>٨_5f)yhre WI4-ޤ6fvvuRQ`4z_Sy`IIKMmw\.p !#ybYsF6G+nG(򴥎w6-'~Hr%ԢZ>)tc-|AMꚱJuS(ݩ{G\>I\ 8s^^߹A|Qnd (D%-+Xn?8 lzIRv/:K]T#!Bl*=/gPQf3 /UhKZ$IPw]݋w2oԇzb:,zdJowX o1jܬ<0)ZAd:9"Nke9Y8i8&)D;u#ޘqJ +UM%!sď5ް4aWVU`@XVG{@L STFe,)5)돯:Ғz {|NU؉-/6 !2*=n<7.6dWR 1dwɼuBTN֍ܓ~ߩ/l O 5 Nv2Y A}y9F+E :ݓw9#l9PZ4LeS;A@I;qd<:5zpàa_ f#B,|˞W 3Ĥ !&-;TVZ2J*2ֵeSkYR:Ŗ2okR>÷nKԳ-^-+J麭jÊmkNıK*ŠǨbUT)RK%~I"k}J5kVql[Ӟ,[]0]}.}J6 :m_lAE] /Ϣ -*q,&u:rQbh᳾"vg[ns< rOh/: vwFOF/^=(1k䐨g0 X'53\2_LI>%%DGFNsU?@:ƕ *̫jyTTep彭K.ϰے,j׹- JҺp ⸭~m;ӱ,zʫpkꞢ*}U#g{z9^.$MrwoPcפ왭+v9s՛v`_oo_R}']X-Vն@9D9)8e :B^;Gcw q'EѼ׽x/slr|c$Nŀ(IE\gF6?/A} }?`A4"|˞W @N' CX`3̢$LyMweH-b6mi,uᓿX6cɐFp%yM ݉-ḸG$0/eNҙU:ْ*BFF<]mQïn0unՕ@7Rs1f.!k Om׈1=1"zDm{J~'Wo+E9?[yn_oO۔ DXޜ( ⛨*:WdxkPZc \*ةXHLpC?f_{eP\`嚐zՏf\ )5ULYHk5M!Јq vְ(r&<,GWL!#g. & &kQ0_B*4ĎW勃2TӬ75hK Kace6EA۴Qv]}FY-4'}f "'8(J0c9p{P"+N}cM¿.o] T(~4Ζь F4dڎs{28[]܈.n34ȱ% ӊ/گ{ɮ\_ n~3켙JP^:IUAl`P92eOC/( XnT+XALe܊[MU2T[ub q9 @艓VzZďmI* 'H~\Hxڌ *ztf/ɫ؆a;dGhʪ@ܢ :h¼Ŵ^iPSE>z;%ޯђQN=M$0(]-1H;,jrAmURRsT ƒDQHJFT Xv+2Vd= S9gLr]JQl+@+%榤Dj fgoR>-mi/ȃ/ʿrM ɗkFt%*5OcQs^ "'=''÷Gwl[\Y3Ȗ߫;~/t":sK6?Ӓ>뜾|\9i:+ {卾!8Z+eLC\n"W6CE\&R2*o1-$Y|_~K6Wk `)$ϝi3Kh#XɆqag{MK2eg! 㜺 yp~$UvC!VҀiX/#)OכEվS{ߒ>}W=k[/(Rjszp2jԍC pl+ܫ,34ᔀ\BF_"+ƒ4&JH0M񳫯ٟ#@~{yRljv_ԛRILE"iӏqU &Kt P` oo|~~Z~?RF1\^UY f@,&RAڹ: 0#d;6cb`%}q7}>X`bʊjSQupL~HF_wd{n༢&K!dLd{f}u#m{2dK/+vx55}MH 5&{LI3 |+JD_: . ^kE&!֭iM^%ԛRIvPT,o 9w h,N,Yӌ$J*(x!E%q}e\tIdI>`hjvjiӷϡ%llG`'k'9E a`iIvo*!Q7ZD2:lh Fa@J~Jf_CC˘(lc@ED,cE,?;OCv6!'m"ב/am oF 5,&۪ye[OmCeO+^RVgAoOagRk)WzQɹȤWTB{PꇹoUJːG˩~*(Qqu>Y*k<ҩuJj>(`P \$֐Y֠i^ +hfqr6 RlψEfr~{B$$sPvo˞h"thd=YQHVv[{€oPWf9R[&1_(>wuS_Fshx&ftIv˺?Bʄ\]+.w+F%ZFxMe+\q[!}c]|k)$$;BS54v~vs~LRud?/oE -$f᫅n髆jfljp>er\g#RkЋ`l+21tKd ;z-H:V mB n/IPn0\β䉀;L[Tnq%"'G (p05扴n$!µ>LYl(eHϭ&eF.釖GvCk*#`iiN-6edavq17Rʍ4]V~^֣ DdA\3?5_Ŷ%L*?f:CYfWPXSI5E" LGG2gL{*,y k*H6ijkoxex~k(Qf~Y߁Dϕ1Bl ֡O0ʬY Wg8yQsm|&;K+zЅ!7ΔV2.](PN$%j[y}/ɬɬO/5397AC]+xU@.z&D6%E io;{!H^s5%r \ KKTS mPMbt8m9TANB_8xryηY6~{ejJeߙr嗰L"{$I몙8 m&&vOoeٓI+\tLòI.Rw)iϸ aݖ"OGDXkjqڛmYx1QDDDEKD Hr"ʗJ 0,K6\ˈ\E|a $>2{̜VbOLY729#V ˆ K9I "e&ٱvlEKkOL{ov $dvm-VYp\Q+ߴ>^V<%k ĺNX%|+b.Ū#:-H'g6)L3x,w5,aGؓ㹟{QcZQc;1mAB2*a[PS(T'DDUk}$VM%'h,o1?Zz%[kW. 㼊*Iv㟢8g IzԞJ9DD$ G>!"E5}8DYDiGE sA$i{O}*V*GJwGinCy~Sl'P]gѬ4GMb"D:(E, E }|e2#β\~RU .R٤G6+IY]Dj#^{wrvĈ}H,?E/*5دr,{8G\}>+n'˅ R~Ш :,UK;3Vie"o}C8DjpQ04-QkxI2bIݪTU[tl99M; c‡JvٳM*_M{AXh(RD9eډluPŁU(J"e)CLGeQ{]d[I]mCW?F5bL;SV$_$rBiKAd[Ƌx|3Џ(Wg:wqwU$!: gՍ\žh 4ۦ̑aئ<2 )ۿ#8D0 oxaԛ/ڋogZ.&|I1NEhڨ;+S3,+*iNJoacmkysȏ ](_WSz׆)e=EP"nq~~ ɲ(P>F*~'.Ok2#^yu.N'Q.J1EPHBq6RkyǾ'R1^8-qhbUM(^kV!/LݠZb qZwd8fL $ND@ "PǻΌK$Jv^˂V1jݶ$hG/Ӓ1ǝM^2@tŮ9UJu NԛZi14'OeJ5V#|B]ĺ-xzBN \ jrZǿ暈UIijXVUsد:ۍrvֶ,c &0tY[WIcn#Ք,bY+n|QIگB?7C0v5B>1PሚE-} WB4ygxMn~V\sp+[^(`o.^(ڨ*<<7hc,(TA"y/" &D{1~: lKBA>}l)'x̚Ϝ~#f4Ѹ:i2_[??Ýs.гLe(d ,KUAiҦϕxsvRp}H̱~ʰs_ZD][ޖe{[Q(6ipHxdu\wUAN+Jk;$@*h3^4#BRJ+( ZP-5qjW} ̫ e_Q.r>Ѩp%62Wj$v]%s/rc9WDłxR -Uu*֬tڥ6s)5x^q/)`gh2`v ѭDG3p&b"g?1EkSt/4`5 ̭DZJ\T9+6*b<;eI0͖yc?f7~ vgK-q-E5 4p,?(J.?_if =;w;`r7G6_߅6EB.@Z8"[$H"Iqo?VϚtdbϻ}b{Rj|t5-GˉI<,;#!I+0ܘv$b4dI@9/1.W%k6%k@qp[y/F jzђNX*tFN8)y_t35>GL4ǯܻI.F}ߺ>륓-hN$%qL* Atoȗ3źd^O+AYi%sVYKYVh*?a_q#y),? oV|ɓ2Wo&0n`,wʝAԹqz?s9 k=RFj~XH~Ks YPh\'ICF+([W9~CUP/X4H ]bՉ_[e  \&'uK'ՖI5%1&@#4M%Se>m,(rI諭hOi门{2}bH&'r˝#,+'{`Rͥzl?J7[Rn* :®*z.,MA׳]Xc(AU( =%BS<27wLTF f,:K2|,F*)Z%ZNJ&e͏LF] ^$F* %d,9doHqqh= $Hfj6>jiU"uG}.Y_2uּ>xM3!rU3:_۝}E'2-amџIoxB~ȼOmJbsDloe.p }e ! WB`Oq+1BAny%ב[ "w7Z[ˍ}Cr7ioN,18\SfL/JF8W;ݫSMTe8S\=4BtV!'6:MFexMdizуc#g\Z&"FQG,`#`8;(  !tpT_7ꢦP?JȎȭHXAA8LFH>^pN@2 Ƥ^=~鎣= D6zZWi_WUSXRrj KdV[ °̰-˦+bQW&AT%Oh e{:^6$Y_dVd e^)`ɷtisDNI(V_a?)j*_m(45$k ިlydf{br3[-v=X$p)]dj #X(VL9WD2p|DE39i@/4̚1I!VmY-Jpe3p'+r~t A*5My,xҥ&Gj"57yS=%bb?!ؿ,z?](f\ )!ӓŏc⇑* mJ;k>~gEXyIMkI+cX{ i^ڐl8Z$pk5hwj5wҕws JQjD'1ZPo?+e me0jcOG~aDR"UU)\ =$'0$2^g Oag^b'73!@ǿ C>9'egO-lOa{&H~S˗5'\YFTUh6Gni2t{9Ev @IRvE#]ͬL\sƧ>pTM+>@_W}K Ҍn\&q.b ]Y__DcwY|mUcU\JN%I;v*Txݩk>ZxY.<[E_)YC%-M.ݤ`XSQ8?|Dm_+4Nw`WjZɋ=$yB]ZgCͅ[^$i2 {hh3kx>Ðy'd[''REo5q6⤝ *U n]Hy"{lg %4Vn2VPKzi%)(uLN.O(QWdTazڋ/9a!= Cmcq \9=aIm]/\mME; gh-QhMҫkpVq&y<1YΔ[WQSͻMɛ̃6x%8zo5!Nd(3o~* Us22s Xo ECd/jYyy]A{ks00h6ySS8r۝1jEyy =U.(Kܱc*&Eb¼ܤۯdmZf=%<>bwbRhNτ˼]\UָWJ uЦV+ cXdkϝA7G[I ƿ%nJސN+}\usi t-D^MO <=/zߟ=AlMܼaQ"nCf B`ِ񘰉֫+: = Ŝ544hQB5gNey (:/ŭM,aT@RXCoPd?? i]"~hc4;|Y"o$wsݎd'&+`^cݖ5*N@YZ:~#82U4([mпĬ^=W]t}A^\;ǝ{.} EH~ Vni:ITz=͊+Gci15_}< bP5C`X!'/Z={.We:RD p:HB#jQ=D;Mχ EB fsU$MT.@yCt))I^[UzYDQ."*%Y Dpn4i2Gh~e͊ʩ߅C!ӕ6~w^+I5!~ *v"FXռm-8R\cf)tk{Onw畕|R!v<ϥ-j**[ v\9>] @dKݪD? ܵWmfVUǴ;",gmK3$$ w^P*fܳ]}}R8mDE{`qTC{bi'j*swCǁL؄aPX4  Ȧv`qQ/j ]EYomF#ݝ%:RGZwI^uڨWLfU7J\f7ۈg\фEfb夠5Fm׷[MUj -= dwy6,䀔T8aP XJrGhw{mKӲ ~zH>UXY UYsUcc]"}\mL/ȗxH;yzzu٘o`} 2l}$ɰC㽑OoͶӝUj-K2ȇq$~rFpFYeYdbA$fS1 I 5̹VCy,b0AAAfh/2Ab1LU+hUd3bE@(5×npB\M^hWoMբ־j[WMAwxIL>h B ,AJkeKIjb 4#+&.cm-hQ|TȤR)ExXX8Hf}Z (0+D 씘Ap8<8ûd1T*ٙ39bm%{+YqFۑ3t0-(Ls8H;Rz(/.7 1=,vES⟱:o}+uʩ$ܦ{d?Jo0FH'dZ"B;ݵ"ޒw,Y:wFE[zqT5V-1}jUKJl{! J8տ F#SuXb7࿛?' ߾ CuH "gbLVOx1?ht(8U-:OXPޝDS0~<iW#]lٝra4*#~xFtBMK Ew n,A(l7AmGrAe\}<<,_GIGkfWr͢Ocvy;{v.^/uK r'eaYb,e}QSD8(6O# ⸮*h{ıd4L,P&_;6WSE5(yX+ UA_XdPh<K,f(>RٱJ 4Sw$3Qфsvt٧;М#U+UW9,>9l(.dTr;2K:Z;h;%!V3$m 6cO"I(t_;@Wq!9 l = 4% pL\c,3r,2F,c&KY%6\V3+(miIVe92VQYpE1`}{׌J>kKt>c]kBaJմUXUX]\e:4Α(4kѠF/^)d!e/,؅piMy#&i2Y 5$)a"/LFNl SR {5Hn>⑽>֜+DڕOy1$_4SGc:2[觐 Kf8i9]Q`GjF[4⇺-'m؍v}lcV)@w9umSe? Fb̹Wbbp>\hmF,. (4ۈ Β"Xoegyʜhu ?XqR78.yRx%2{F]%ҴuQwQ,5MucƗ^3J"j 'NÛ&TC!X N㝯y\!V-g3X'jH6o)6)qE RŢ`gyS 0?AK!Q<Km0y0S*IU(e! 5uy]͘tm" u[Ҩb%2Pw2ˇsJh© HEّ Β"ٲ˟5FR˵% z79WF #%3#%]hb< ak4ٝEusJԬ28_laJ:uI xĎ"wCOZ ,;RA|Nq^\ (jF-_׵s8@<1 ^! 1jXռ;ۆ9PXJ F-1 eh砞`)yy]͘tm" ([Ҩb%2ߑmùX޴ivTUQ\-9+WHE"HR)/{u*zX20`"NLA3s5MATm$SFP\Y @m5i"/D,"rrjO]ή,c2eNj{ZĵU\ٺ+t%cR+tz m1&$\.藴W)䖺olweOPL=υtenFv>@NU &'B;]&j[D(=uCabX%bX%!!!׼"T\܏>!%׮d X 4 r4D"C.R#xgBDwEb䤝^o^DxU,'PّD3IЍEqD%ˬsuwb]TIKvK|{{kwfe:%gEiGQmŒs4՟[R*bWSb9%&vŸ_.`w:TR)Elr-IS3PS3 _dMƏ&,$ +,dm}t~Z"i7HOzS2XRw/&&;eq iZҁ|v&D #wɊC5ԑք0ԇ*xn.H2]Yv-Mf4x1'3XOޑzKZ{엷[ 7OD,Q4Htˎ#i|6f4z0F1>H@ #H4#BYHR)G&|rE_V6LD&d|V b,`TB/*[pa(25v>$Q:Y|ҮoN<woIdMi0-GJ!Рƃ h2id0<_urw U3p;z*p[>EŞCaM$j'sOzl Mڟ}>nw8*2.Q+$& IL(9r2c7Js]7n"idʗm,4qZ فvwiEO晦3KJ"󲖋ǎ4qYtYzKXR!9Urs-J̉Dj54覙`Z$cE&u=Q>¼̕{I#8x!!^ #^J,]T8}Ν0BaAH ~:+;׋DjvG5ŸoĒyE!(9 | 5Ϥ)g]4jԏ:.*o-KVN"yzEϵ^p1mQ4C Q'W47_[fxU0ՎA*~#D|󤾳<ȃѫ J5(֫bEGcќ;]vM(/肨%\/\{S#( m*9N]5~k ezC'2|I:X5^qjRGMmj^?jmCs?I~KFdRjz؆ښI-vll]~/i?@]Aݨ*%5m/֗M_^_+Z=\O/6-rN"2ntLCPYbH矾&jr" 2Fzx\^C:D'u'qgL;m5XK.e#goofy;>V+W*RJhkN3`Q}65}'tS~S mV pUKFV2|Zڮ깛_wE/NٕSz 6{M~ۚ"+[ֳ|{MsEW;v;ե Oceܡ˶Z"f=S]1@KRDnW?oWs_=n˖m:`i3)\B&$DZU5|sꒅ%^AF, rsGf?T-s)W0ʑѺ:G,F|kV:hR{)'ml3}HИ]]eܡ˶Z"vnc0U@KRDn]s%~K_=n˹kvӦ :)\B&$DZcZӰAϪJ{ eFg"!dDi3ǃȌ#1Z Hm7wyԟ\*Q$ ǪZ4r2ΘND-a<2:V1ʎ)ˇJ.u*K+Y ~oWцc1gN!4Ј=d6cv}v扻7 B ;Gkw]ANI1]X~w!b\i]vr?T@g;Jjn{|9<p0ׄh9/@f̌Jזc (u QU@06]Tc]]bɷp̺Kɋ6gjj* *);ζ1^^[C }e&{^26HYnKR.nS+b0Rm ǬU^)6MRAݥ\ӯN]HQL68b;<+pz!' ?ֿ٦ŲvҊN 􂩭)CȘ,d-2@ر鶡2p]H^GmN_7tDe[newMmPC3|o.{?!6 gͻB}/7\$N뷏Z"qT6rULtvS֥yʂ9Z~rnƗƱvAUȫ;.GGBKTF/hq9DF(~+$eu;ەC#ƚҴOrb07$Xɨz$`Õ=g4 MW$GhЫE]P}CO1E]Vk:Unb*]XLLWw_e0'3\ v\V!doR`v5sz KV*R1X6UC n"k3AA" 5xcͼ)7[i'x>rҎ'`6!%x~O+Oxuㄘz! bry^7USpI\ڐN:PܐY"ݠ| ΘrXonnB!beAx~$@A*y6L4n3S].VTngR4b˕Nw)Y#Ԭbf'CAc8 PƄ(!s,83/-xh 4- ,U:fI^O}Uv[;z W7)ޗ֙]$C"ho{+&ϐo (,PLlz]~e1YQe첽d^!(>Ǐ2 ) fnʘ]o[qHw:.ש.,f@TW㋈B-}GW:(D-ܸƤ5Cm $m8z,c3X}N5o)Uq˘]O?xl̡r}{;ZP_L+vˣڻWVuS%{xָ\IeT-$M?瞆ٳ{[z/\<"J# oZU]:B{*/IRw!K".].z\,ۄDEQ}mC/cB#%c3\H2 Vh8\ =ezjZꄏ^FR1H ӳk: L%PQM=:`j!@垿ɈyKIDF *lR&8Pu,p-D8>:V1v5J=d)}Jx>_5tu[v^,ICUd/RmڛqDª+*Τo ^ASuSrQؿ[,Ft5I|~7Wke^KX<_ޤn[yLGi>W#ҏKkY6ic(GW*c_!ib;ud]<]켪8 ;HOR2JL )Ul.~v$o%Q krY5֌BD`xt'^)xCP+f:YpN VbBUvIF2]EO[;)Viv[ RDU#43e` hKRd~ٲeI_6GigTvcbݷ%=rVnv^kv9Y-G[uS-\q7'ʫTڂ䫥!XU߭iX|*g v]O-l&ҨR8f3@quPGO#N7dċ^?&)^i`r˔޽]Ҳ dј:HND0jɗ+#<@+. c4LHAʣ"dR>0HKtce+,췿$ hfCYxS/bflB]2'͐7/,$ NQ?{Hwk=o+0i+6{j[ Vg؛ijkQ>U]ߦK%]/zōgڬ~^GJJǻP8t@g;@`2b~Sh|UKg6t)3z< nYJ!/?ϭܹ";vM HX/t~)E\5xwy P׻%5֌BD`xt'^C!K-YX/GڪTJ%"Uv$c%TѺuL;)Viv[ߒ\y3!epԲgflB]2'͐8q 򤯊 NQ?{Hwk=o0nےi+6{j[ ͏ZVg؛ijkQ5ߦK%]/zōgڱۯQe?w(Ktgwzcɕx~_I#O5ёnKIrZ包yIN:W,4Oɨl<Ǜ`vd\5jeo@-Xz'IDN{!*!vsWʂinVChmt/nv}VƵ}II?eX^]Yly*iYSF՟I=W{Eva # GTus~oyז(#&m͞MZsyIv6DQ0eU#ҿÓcRAۓ,D:/[9z,1#IWh#gIF,%)vO~ R``!^_xQĄ,:ƪb+{CX`)IZ "RI l(BnFPqƨ18M~"tR*Ak_/#ggaIOF[PsfxX?Su_:$ n̖ Slˍ%yI;5K ڑFL9'Zv)r2}:^͙SXՒm|[! *!M0EwΞ -iNc u1Q7Fwb#i\SGyf$'.vvїƹb+ ppJ@ o`*}Rd<=WH~aΔ)O:N~Q d@:9Taǥ!hl.*||t\|\&tAIcx̡}w9K!h!V=į/Zn_V˅b+}Iߔm Zvwɛe+3Q^,߅~KӦNkgNFXm[SvدyԶ35F!Oo̦}"YSڋY⶘y{vU:K r~xT5;^AaqygM9V,]b2vYY[5)*V%4!CXy:٭w=B$S.6s-!U=~NW fO]|NWv 'y]G ɄǞ茮 Mm QL҄83o'w_q~ƶKjg (,źёi1 V.a/vK㶘+NU?LIjpbqNZ9 5ceϔt|4pkj'RtUh``ˮC2* lDzO ș*eñb(kqE;] HaX*Lh3mܩs2?$gW{ S(w?̦fMeKŰ[6}7TlYEJO3 14yUi׿&j0//5 mkb=EQQQ]11Ri'vХxO^]y)[SbJfh2|!܌?ee1:L:y+//7_:uEWputs^tE"fh2dWceäw 'QŒa"ZԜ W'C UuWRZL')xw~K#WjoL k,=Ez M t$Yxio`^\YUK*$ LtLtLQDTEDTEDTEeX]⯨V|7YFmy/r%3o> ÐFu 1W{*s^_"ntkދ\g1yS艘b/Z f0<vYPa&tYegYG@ulkkpzbh3!Uj;3^Ji33[E.h^Z{4XͪquSU'^'^E0[r;UPReD鎘(KR#q Q&)+sȔ;d3C~jz'"5둗D`פpRe1c'7ܔ-"š_dVaM<2a?=|`]x2k6yv A&Yh4l1W"jӟӚ,./tʃAPQ[R}KS4Ci N\b\FdJpr4'QIj$N@v` r==}9'V.YF d@:di5:N*Д=g!c!90D?c4<)v/,;3Ȥa"5#DM E/l•<\E nB' RDy*0.&Oiq{q)yKMCj”:3UĚN])!EJ=1- "ܗ?᫸ 5&7t^4M qB@* b4#Hr,,Xf'fVg3n"KǽW^=bf#@ q®" e'EN бh4,,4#Hx: H M^q?ӄzZ3a|L)` ^B.4a0i7Y˄( RP]|Ikš$Oq ~}9m\UeleeN}a^$03Bo904WgY!0|P~u}Bྴ.jnȧm1UR2M Y1*fd\ X:Ң|nRجnLNCHB!Jx0pP:JT!e62>G< pőjIYoLPNGg?Y˴#)w is1643[s-%#.+(cOuWo? Z/[19iaJv?81zU.ֻWZ-/$UZ=PcX'{vNJY3+cFQj5 \Wq]eؠxQ~υ'q< cHpJ!Ja:ȏ~=,tBi4G[bY8^p%8>T1"|\<9!1,S +[6:F1S\?NkIO,\:0_|>*2d.˄u[~Oy1]\g-1ށZDj%b {YhxK3~&+myfA 0 ˌ` I|4*/~z`ױLP. Eٝ:$#eJoPc.lU,zii'fXӪt̸IAQq G%c&WlɼLcd("#~B郝ni{v4+;[Q<"R>4ywU՘m.r:5̚iheL[VEpog74 B]IoKm˹=V&{F؎EQºfPZX^wkƽrs?HBuY5|Fd~GIuǖydê (pudb#̩Q5 le͙JxS*ҝ43.P{\GL%c&Wlɼf i<>_`fp6<76Qv:DŽ]Gڦ-,@5؍GVPk5-9̩j4݀u~&恁tC\+ڒA~M]Tזw'Oh[(<u11Fl 2i$I$D CkW:"lqxj@C>Ջqg f P"<yV8gRL9C&_,WHZ9E`kN3K`}ƯSނF%4. ~ 3pJCB[zpIBAiO#9֟ҘUrn5jmY*G03Ĺ4̦(68.#.KZ7 rJ윜JE"8ijQR!YT@p I6W^ R/kKUԖ+ke؈vxړ1w@zm_.#5HL,9X.M,9y= 8 PMXލ(FG:_Ew ?RN S~:jWEW],\mhV*c(nB;-҄&F{P[ȧEJZ3곿 m9(  B9,gVWI8H3 l[aY޳>֨iCqT#U]_B@Q%I\HIκYwHhu+l`i\'KٷpX؉|q+1`DVI7 .{6-0i6$&[ЯXIj(֕b I4{ꥺ)M#ޙfӌ,qQ9#@@"SK= POm!#dVLg>wdUaA;^@-a򒵍mW1j7̉NO%zNm&YX~k%:9WzJ%oXTB,]CN{gx}9QJolFnߝJRHެg޻;۟#o/eYykWqݺhɴ1IP^VǛS{jwD[Ho_a !7<QJ"(g \S[HŨPbUp#CXL؆f"' w+0:Dӕ]p׌HMmF$Q[BMOs>;* ligyIZƶ)!>c&fc!8!O|37A,?5k_%ne-Fŀ\6G63`ʨ 7S#7yShwvV_)f$oVNR3{o\Y÷X}zY],֊n-m&9L|Re*tBrձ}R+m9tObFdU҈*"o|m"զ).y1ainwy9!I^ۮ NsW31 GEhM~:" (Fk w$+nWE,'f.-ndLPzrKpByFۈ%[Mp |zJ%oX̢@1ce"XN{gx}ÙU&d`f2ߝJK,܄^Fc޻;۟#vZ[_d*1ۦlINzIPa\ly>*_GjwDjJvF">}R bum'6sqYo;* l␌9hX]Y3H\I {n Vnx5_=(7ic2ō`q,ba9 0AeTRزC;~u+l/r7r߳zn|kc1e^U=o'x&RBrձY(R+m/H.J4.ESMoEmǫLRfE5:,2slC3KJS)ݷMPHAn lv(h_ &M<8ld+' [!%&kÓ=jqPq7$y/>l|k+}%ޯBuT}bO1o[&jӡ^@|Y}-Eҽx-Y[zYY~g*mXadYeCXuWѼJ=r [{}vY>m(:nݧTR:fhϬ)-7\ęPҖTPhL1].c_c+!͙.[tk|x) %BXV*LDPSYq\3V!!&S@ȽQC^s:C~g a!7?S{[<~>)U62I/e׷Uso%é٭n_;p{@l׉HJ>vu$&>Ȏ÷ml]bb|"4Sh @iɮFf^u$> >% U& Ujl$nPjֹb#"GI%uiV$d50_9^ܗOT!R#BfMV*ȟůe4\U:g T]2=ҊZ|O(\Kp6Y/yTs_"\oDRb W q\t_SsЋIqK~ $dg1lɳ|(8'*8#˚d%9,%ZژjRڥnήQtؿ:m2$/ΙYվ{{j7V%5*x]B2|$ E-TN*|ѪO~}GTvǔioC*פWmd(1>wW(:3"Yȳ(.M68+uBIR4lB\uD|@!U)9)Lڅ7śYtxL3y:n;L\[EΔ>89í}z,E.g 9S\ShʴTl5t΢wNدi_bR?֒v|-rI:{}LL(8ti[}0Kpd&Z;;J.ru򂔙 MM}CgxhG4fX9 ""I_o4ƒoy3} lfG >ʲ?Z)jf@Ơͩw) 荻j*dr8l<"Oѱ/Ɗu.KVkz./7UŀnRW1]*, ,XFʄg\%{|ceH)"C"ڳ9e>ߩE$==U %#(ў`:XUzB`?ZJ{R^U.-ٟDɺ zljNqoT\^Z諏c}d;^}?-UzY;ZbFX˳KV+BְǦx,x&\{zo~m ޷|.䧗=9`$8I-d_YTlo}s(NhSO:cH3jGz,"ЂȫUi :'X!q9j:=ononSa A*v'KTM"TA5k]R/ @Rr>e?/h ` Ezqbc&}aٲv9hz~գfx?DU*G\3mPUkJ:ƻ̍(KiBܵ"t]ک|`,=36/{|:N*|JuY7Vzp[Yw 9}0M+Qz.L yUGItIw!]W/ۇ n=uW?$( u^7!2A =3y5KŻ%ZX L9(F֪ RS x=<Ĝld,(,5w|ÍuX>-f"P&p#\`7Q`tYb)E%}&uRθ^?%@EpxH|.ixNctMa*?0C=~_?v?9.huĕAo\P NLrOȏ子ZK[;! kDZDwnēlQ)֚ttRU~j>Ӎ6YxlG$mZ/G Ŵ80O%/BUaƸyGVn d'<BG痬~ {,R#LR-`rTnh} f #PG L)%EǴ9a&P zP8@Ȥ5)6(5lY ]˺vų(▵ B#\+jR*>uGOV5M[Ca^,Mr:z'cTTt" mYyǍ>}!w^԰kwNgH<أ5:ʥ0/"),Ն~GFh+LJUl]CLC;dU[ \~i . /dmtjɼ$~]9}␬.AQ&,Jҿ{aU\طu_7꒨ osֲۗ`%nwsd E#bjBqE7Q=,}SlY lNl5M.#S~'|ڷB,D53y"cG""@y%{GHB (Tm]W}ԓpPEۭo|ޝ*{ }/U$+ޓRgi̮܍h)1bE#a+X\c'ly.V zF WM(U\D55!8J\s)jkjg f0 J F”RBs8n',vZ[؟ʳek@idiHx _6 ̒JHfY/윴z5 \_HL2,ݕrr7=IU9 ^;ˎzPIjgf"8Y^ ΤEJP[kM*B+Ɂs盽ɄyMՌ@(Ӛ8׫orȩ7߼+C0{HIx&_涂F=S\TyN6ʷ2ve]!ҫlg)S"ir*K?'-j=biDhOdW[s>FW ҪsE@Ewm]"Ijgf"8Y^ y&9*ƐKq@n%1JIoF#:#_)q_쯆X\Oc7F!4!N5a)@W o37Leb}HafM]m Uڈ{j猹Ŕ+6jioXe޺! Y]+lg)T4f[RhϘrj=biMF[Vqi |nv1A4NS*,0-EǮ KVk;17rgdjJD"pƺ LdүR[ѥE%\;_)q^MpWЃ uU qW}+2>0{HIx&]kh*mD=S\iG4Gʷ2vebeJt~jT=M1WQtqICrj=bׅcrKbQ%aj6i{`*0TXdEwm]H!Gکv1p'G?x!W(vM&4B-P[kM*hJe%ҧG&Ϡcn1.J&oq 5y}+iDBkv%( jwѕ!BKks[AVSj!2OX̸ _,NٗtBg4Z޺g)ScUРFQj+3@d!<*sSP,Œ"±vu/p}A8UE*Z%i{(.j>HRVs ;ϳOyKvU<,S 41DIYf&be IKȸQr(T Y/"^\SCؤZV0Dſjow=p"zZۑiUdU)?=VTмӚ%/ K [#hAR-Um  7'=FT%sHyky5 2]0/WHu3(D}CMLAY"㢺(f j5p!Z?V eCŒʷ֋v$9' Fֳ J9ְz?9[z;BkZǵ T0 Tas=Y)rKvxS Ж_ Qm+ }03f_+VhHf XPm8Qu]0&An:DYo9.[râ|5[*I0QvDFj9Z1K%/X豮'Bs9,KrVc֏9?}^+!SjhcNʁM -D]˻7Mg~Uݺ]Pbcu3fvjԠJaj^\+,rkYҪ2]X-4Vɩs5 (N*TYSUumlJ6*R;djkDVV?yd[=-aVA5܀ d!&Ud,D; el3S.1JŘoo' j 4TI hKkG4}&*q=}V4Ƶ/P^39Y?DKz7G=PW5.Ի~vohА߯~b~}Ix(dlg\MCH"_5<`LDDDD!B  ~j|-|U$t P|z?=^bqT)-DMn~0נI4AGhN|%jg@@m~TUmOgl1#"X+G@u|o-W؁_8xp8ȆqpQ=!G7 bWMN^_;A?-9}68{T}96QֲZ/xz*5$)##q5[PAȁ3J),!j֗+kR'~ 5[p$ QnLkL%ty2!Y$݁SsrcmoPq.^:}->d~~ 1 <Hsp^r<~D(t#UХTfLjJvh4[whUfBd~lwf:~J ץn;+(Vl)05dEV@5Y5Y5Y5avj4 CυC>QL fi T5@ٵ"^~1w^PH"gv ŠM()7$iz1uR:? M?>:(Kԋ(-l n $tGXOieQm@ޤjq}G.z:(i5:&V~!\.U|!ԧ['Y4ʪ´Qп_#BҀtϦg#09K=ӑi+ڈA\Y aHtQDHmPjbApx ( 0ҒV^tvm0TEUBJ-.cf!2HSFʭSG+Gхr[)ӏU,ζP-m*f\{PkNLWv+R jEm2k6I'ɣuLTMOڳ{S5ޝ-'2r>O;+]!|fE1?q#s9F }9 nZH;sT5$蕳P($6(}_ \R8KU]5$ԁ]yYjD߅AL-}Qfqm*В 0zkx\=#QOThM5MhvidNu_zVDҖ)Tƀ2ڃZpc+#ZukR(n< LjI'ɣuLT57=YmI '2#?xfEQr;- BH'PHԱa%'d3CdtI#n*iƢSV5)B-l3j08->0ū N?0wٍRs7bq"E*CpςDJ럭L2 SȤR)EZTGVIDx4Z'Bg.A~>ƞ0g)q]Mv<"5.^21n5hFY I5pkw#ۛ-N40Ik0IMSdPK):4~=i[2U AAVgoFH ٲ{UU#U3V2VUvzD}/V3d\GwYpH.,ݾ'{r˟G 22JO[LInDt>)àw  y}tߓtIK}(l)ګ-z^m 6e/\~OORv2U72?e\ͪڮØX8Hśy7||]ǡ?rpok ˴{v&ݿ*Z"PnSP.UZ~]j6j2(Ү*hUբKQ}kqu#(Uh+\J9#l%_[ ;uv!sR_Ud" 6d/cS$ևiLБU\Gq.Մو;EV.ˀzSFpYBރ ʣw9E?_{V:~~TGla5]%NjYѶ6 * [-QG:VW:=M|sV+ʚsu9g y{Mw@=]c ,Z"T/[˙2"{utbWw S>瞪DK>9M]kBQsN$%'NŰ'e~;YvI#Kz^w5e d6MSa&mOm{.U[Vj^_;mzݮ:H4r\(Z$UʣW?;N&}9څg:|T(*/G_ jQ^&mO͹o;.U[Vj^_ݮ:H4%2I]ʣW?;Ng9څg7/J$)-E] ԟM赔om*f&b_E" *K%DOYЦ/ߢ'葀wLY%J-;sP `F;nR^5M[.f*h :\us7.ltfwB$gODτ2'wR\v{*= i6Nw*"UҖ2)Jȳ ZQsDjuPH{vށ(#KBv2Or"c}W~(?4}%o6TUn,& Xavܫ8^i8q$[LʷE|5.d@B6"ULgE^ޱ)墠T0|n[բQ^MQ[a(ԇ+r׊TnMENReec"~ Q=9KB.;_#v;9ۘ;9BnpՎXq[:)dWj~žg 3\qo(كVS<7KjE:!ݵu""iԫ_VK>J+Iu;XЗ-Xjh 9%yAb y+*".p{ȟ[2T_," 7FnieDou?G jM!(2c",xs-K #^U*`X)R'?4ߋSYqptZ2 3' ϧQZm~EGhr~']?ˏ5;7N$-#\aPf~y^y̕kBAhw0R&$CU]0μk*3k*nalvTNU+7K1I,G}~Vh{˙e`Nhf8E6Ю%CV=b }U4] q6H5J.\]30 U2h68?(FV&~ `kKG| 볅n(tI4dY[DKN/i]}&-inf1$eŖ5˟:Ê[qHi&4-20o> F= 8Z/%\?m=Xݹߢ/-k,TQGJ/=guPP%iu/W{sx&b Uk^%YNAX4œF1WBQIA FDFU )Ի?o7,iDZ%v2?eb"]]k{n,eћWBա}UbP^J6ն̾ߪ'9Կb$_.)q}K%͖AبRFۢTgh1RiҎSV[]ϕ,#qW%9tEYHMFC;_;)S!PÛɑb٫zA] ;5n 6Xx(4l8e9~ˑguCɼsnĭ6_ 3hP9M3o)4ڔ}1JlG~P# ١/<[clnꐜC{uӭVgzɻV>ά6=LR>-= CPLhb}7t8[|'"^s`kDIjL,~.#yLC+&˾v&ߴ^׹sʽ50`ۺݣ~gM=>AҶvqy+3:Բh\q.~ ^;ݘ+r-hF9S))8 zrU&-=}G`kprG~t|c(qbvR8klVe7TݣĖ'̅"Jޣ xt|mJ{>~|\#b>l I\nbgww$nHNE޽V;m;O; g+Qko)FK,׭hi2mDݎfHM 2w[RoҟА ~gоK wUSww9o2R%=2}<,a:%{ӶA C-Ƒ %Q^"4w7,GRmM=U83CRTIcUPC>1D̷sHp~ⓛE n$f/ v}C-hkCQ_`߄ܩz:}Ymͺ~d;9f1e4y_Y5c5MM{j|{ruF?#P%)$#AJgR[ý5:Lf &@}޶g6%bXz7! #݂b2a̦c_H @3wecj2u@c3C=ŬaԓMZe 6X'drhqgB 5?j>SR(etsThP iUFT٫3(v߿NVu#FjE)4c8Jȅh/}۪;aCu FԬd -hJaj*+)E1D8g(o!2p K@U-78B`IQ#B'`<ı(sᣜ8 vtE:T w1e ¼LZ|(?Mػc07>9ku~a3iʹ4aVjDŠ/%6B?ׁJF 5Аr;T },OnvpB3ե5ܮA-"X^xׂdIh]RuğebJ:ĹƢ<14d<E/%첥ydD_PC1u <Ċ Oa_kN1IJp>v3B3t2-l0E,ŐETR˙{GMuoԶx̲ 4bY qoHQEWYW2W>ngg;3ե5ܮA*ۿ5X^xׂ)(ZTq'u `9TO~n~ABIN7~XE/  'TD4C7"1Υ^y+RRI,=u+iݟ))RΞ|@1= Z&1<>'̴yZ04qAQK.dR)5ս-!2$qCegG gEEuy >%rnvpC9JZSUܮA-#Ux)A\Kd'HjRNO@@d-y^-.A7+&:Pm1([o:SAސ b^yk2W>nvpC9Jե5ܮA-#UxRd'HFRNO@@d*~n~b\4;EzDe\e\e^t pWJBv&;nP2T8I\VZ q,*c2ȡ ;_ɭZ].WբRh[6v$0Y7*8xa n,ofsQGX6n{%kc7 8Y RK1 [ަ1M{%{NIi"ef#D7dX태b;G)" "aL+J7I(u:ug!4"V+hlު=.*ҥsQ(XݤRr-u$bX]E9:Uvv|_E!O؄/E%v)ͥ;5{)a, 6G|I6V ?t+ 57Q*r{1S Ql{)heDR$%$CsIaTH[BjMȗ 4Q9J|bfF2B.ʲ6ԕjʀAUԣ BC8T<[5HGgB LڳF 1SZٌ2I3Y(iFm v:6_2xO4FfoI˔B@cq-tsm>>:J *Z]e~z Dj[p!֌!m>IuEg>YFHP t:BVHh?2ECO b=9>I &\=Կ?7(x~_< \4L0-󻴒[R.?vlF: y_z=BRR+fBF5M`c-faeCUs߁&Vrޗ4K=x/JW >HNP$/Tga^vjjeNYDXߓS&.2o6Au̷AHk֏u"4ck/ _GUxy:f>粸ֽgB!"b )NlXrE%:~r_7Pmhu[y"wcYdYaOҞ'_eA:vVb UF {Y F-椻X. A)__~T 2NҬ))ZbuMK~W~V]Ր; *h( `I>4UR02ië M@ oʵOz40!WL-[T-]5!( N}Ŭe)8~+O]d*6hw!&:0S#IEU!2iëĂNUPO|{SPLjžF%n@'Sgb)kʙ$MU-TڦiMm(kwuA%$]@m(/6Xy(RVm.V3!;kifqS(qWq2dqfK!K-W;d5C Ot0ѹzZf\)8~7SyO)5tB4 ;tbTEa|IhUR02iëĂNUPO|{sP 2@JS-G. EN?s!$*Z'ЫAm"V3K&P䴓\s -^&C. ToTVxշjsɽ%b#EW4Y%Nfzl 㪄1hjɬk۫*YK-5$t/E&kjx~$JhًnpꀗYL7mu[fXX77 썯rє#ؚq|E5Cĝ͚:5/ 9E&£M5epmo kyz"hCX$'z;<O,&@;&Jku}_dޯK-S*Qlp>lݛ;K0pUG&\] zWmi7cЇId.m1C$A HDD*z] Iu bB0 O*t$xƕ ڞ.(e)C*YMr"APXXZ!㍦p4hd jTdjԩ9\Jo eV8OSf`hJvx# JB,gCGY8Do\$a5nuNaNx,&!&oVF{U+Um"Z9]e^2Fc^λ"ԩ'2aҖ&(l/hC,gƗGXWS"j% \"@hk|Ԉ*6n#9mbaBZ $y;Xy*5׫ -Wyy!%Dq7 q&X/Y/6l!/,dTbtK_+%Ȋm *O{qS=; x, EdiLK 7t!1;|vANW2+kfT2Eǡ5^s:>xJY_:oa۱Qio6s)mĺ"l PV^3HO~g^oRؘx$0!TL- w涫<<#,ϒϙ$Ai$[вf2HZ; +=/1)t5JBJ,@tz\_T#s3(qPճo++GV瑩*j|wf )0Yۙ}r_ۛtӳN{Ӛ(P0ex…(Lt5n85IP/9߇*]XDҒU!S_4x%ھ(]l@J%!VS:\f}Tסޠ4ghZkܡyk MZ() +c5b j&2K.w6ߴ$4r&ԪMh@Kh@5Syjn]Ӓ=SM.D'U'};!5Ἐ" !\W[lg;3}*%,,OX~3 7${=h u9pR@ver熹M^HsAЃn=D~)tʸ17hpb-WB\(ΔIN 7^r/Ejmv "}9zɸкդSϙ*UF>` 5~[\6E\b ҵ~k[ sO\JBl$pXU%D y(mP.frMFR.aC.8iR:1@i-5Mq /Wr Gu ĜԘթKV+1/1!6N2|R%惥muM1WUJ!DzpiN"\Z4ijgJ_Q9KRc5ԯMdX//R_7V#OVQCJ}XMkߧi6:th4RنhC:VooK<\-W5344cJR$:*9(&JQ]BL߮IR(dbqhꆂ5VH46pL|M~U*@&srIk.f| ҟҧSs g9KRgεKky2<m0u)Iۉ܋T}<k^ӊy͝cZk2 kp?'V1, sO\JBfLK)H`|䠡Ejt] 3~&)Hs t}ţ|LbKL /WrnD@NĜԘԪ ,cez_$#vz>&m @dR0jD\t ۡ2czܹdJ[rA5SWnPӕ܁pX4@(w/ҀP^6y+:lGT\@CpSHXfr)9*cR$xEgѷ2FT6{v )&i@X9aCRsoZR9EX #RKQ%n T!#o|_Ԋ?PG:[-HxF5ШīPo._l*5 rogd"@ש6pԥh`MMS9Z?#D"R mI5Mi]ٖ;|X 3GFGƥ?.Ոru9%"'& B$M ؒ35n#V/ [BIDu, S$Tr6EҼ'kz;V]SP ]Oȃ lF9%fMw;MRFIgl]nlv%ڱһj&hf7~R(D*;Uvr_]$LbBf`NO~qp[x7JM5IOˤ&qKҰI PzN= 4hxF#T\<;g/@PF>X iQB<[OMn̕KI†Ԑa 'Aw̨Opߦ_|zD|&Dlb0IgBh-6T҃84R [*1|"H-Lb, u^< Ne."Hs.bNˠO m$(-S*@@1 O"i5jK#=T y ,}\2)uSPtS hIc̅Kh+??????????????????????`OOrYklKı,Kĭ=6fAՒtk4Zvh-@>-s$i(&+DI}Bq g7u93>ty<u_۽"\/XfXT6H;ɥV.{hO'B:,޸)Q0 /eltOU:nV5L+9HXCBAdϔ0U^ڈX6e%uVu>jihvU{FR԰_«7LC݈LaNʊHFz D)B%"Iיcw5epE{d<C^ԄTrȴDjo{.Co>t䅏aci6XL۲ҡ &q'u!ꅇRܫijU7Ǖky}L,R:" mY1b{;>>qyaֶc]{UME'eR_w>$vK7?5ԵtuE)xD`E*(T7fI .^ zB{[5ʻ&B dm|7dT:E19loJP3X7C[ɇ܂ |12 %t@5u C!ZX IO &/,`r Xc:I8RdVd[W:=iQN|m+iؠdϛ Ws>*m-1=.Z:\YQ۩.<ߜQ⹋GcVؑPDB.5E*~rp _kS=ߝq&U2S*UI+`7HMO-E \o*r-`hA~PC[|`*UYH <#j w~Q_/U+0(!Z= _;1I8RLoJs)Qtr[-o݋isŭm%eOGʛjLOzغKĖ?jokjnNcmڗo(\#lH\!Pޗm-TҾz,pS=ߝq&U2H2UI+`7HEQiRj1dxh AÙyuXN QN'@f/tb ɺ'j w~Q_/U+0(M)_af<{ŌAopPtp*lK}jt{%6lݺO bZ,) g͍凋Z$wV6iYuTω-.~߬FzVnNcmڗSh'QdBX2b|j"Q9# aMd(Dћc¤an o 'lʹB4Y3t`H(w./-ҀLR 5[Xj'E vXA'؃ݏ9k;wv&Vֲ~[mdćt t̽,ln*-3!m!h=puJܩcl1jXUk9˛`o&vS#&@`4-W//;E$)N[ |hZEŕ~I5M()KEAs=% ;q;/S>|ܰ߼>KWAiȈ|5OD}7CIZn[Unb+(Oy)[Xe3Z/%4Ɯ#M2I om{íFI+%/}=7)}q]Cat+5W^y\fKd$_ Sގd)7 ,I#$ju\鐦J:lnQ(J#ZU4xр\ER9w;Rw%[h'a𴑂aN>#(.RX*|oZl>[#x^OTVԞ/6I̴{)y\ݪ۬y֩Y Nא7>J?fTML_>xND9'$k]tƞtU~)PE;)҄-, =y02rM9+N]f۰I`~?ӺRUO168S!A1HffhDyQI.tQj[- +OJ0j(r 3q&9>mQB kcx:LI)a(UuE5WAP4MccnGɚQAy!eISʻx Od//) sC'3DI~dL;W+P'uT:~;";~˿?Z9 *&y\e_ULI#P@bW3 Hmn~Sq! 8Ձ+S)@jAk;[^k[[4itUVJ꒤~gp,=y+oݿ^r7Xs/Ye(ǧ@kgN'/;HyE{4Pi*Qq.NocվsU~YGS:=$ab5n_SUq6Up%Q[P=Z/zRww1Wy!1Y8J6#u]HZs>i#X2 Oث䮼[I#dYW&1ޢ!w7Yc]o2g-΃]tϮ^=cm mO68z;ڻe]Y[y ?юC#J}Z ~s!H۞/u ,ǖSn+of.:ЕoI0 358pl+(v`Ǣ#}'iB؛eY \s9ub TUI'TRuI'P'rr[=}&Hg8^v=gpcb:deyО888qPbQ|vW.̵p}nMvI#xe0}(A]:KyMlC5QfSJm5$)ZzkUQrM42nWT^8kH ,C=#2yWr ^Z؈_A:|maW pՒXyJn[rB )~)aEF\WIiaw TZcuCL$UuíEevE"G#fJ4=/ h- zhmD}Mf͸T@Sށa?`(i>mT0 t{_ ] !?j _<!] ěÿ̖XYB>2JKIٷ\Dps9+cӝiHLۥn7MQ۽l#~-Qb;]Р/,L|g…ƁfTI (̈/E+⸮Z%!(hXHLwQcE 4 B<"Ood蟢H U'mw ( 4TTS wOo>T::V}Tnjq.^;-FemekDdT=הuT]m~WQV[zKJu5u4N"cu5SzvVQ}Jh=KQOdSu%C1_J0@(IvBFdOg._ 8 BJz$ֳAUP*1Zh%@gkXzEj 1i/K]Z]F]uJзFn-(>{3(|6̏)XͺJT-W` u?eX]aoƾ֫Ewyڏ>6e쇭9<)"8! gߍ}]tS_qi*_}v1)_[r?Tyjĥ1 ưb6WUp !5jjbj!#Z_|޹ ϑ%Tb 1l{@AHIPOX]d:`5Pm5jKu G%h ;_R+Бl0sZ%bbΑ(ݗB!\֔SAOL*{shO2>bnҭ WՂc~7/"YGD5}dl[;Zk-%]^[j60eq Fi):^ RzQ#bO:GׇWS bӼGB;sOW{C ?1E4HoiCøڦ@*VЅn W٩NOy{;X F%>WXi)QwXP"݆(-.Ҭ*.2(N%ytuCWvĭSN@_oA ;EAH"}L[i*$xJS;=A#4zͽ1p KkEQ^UE^KCү![k aYa'Oqb.N,&YbABh.,,XOG}245]*=n;1Vwx٠Fy^IJUٖY4㕽egrҳv6fPT2 ivaVqBh.&A=s\/|;DNU1$ 2rl h7 UqHQ!0FEKA2x}CǗ53 E}C+5ե=uROwO]iq[AgMAw)c-:2o7q^UC㳓Qyi\vmXe"3'FpO ǂQ0X.[%[sHm SM6SabX,d<,<)@XOVƟ㫛ڹ$ڊHwȤ6o,GCg_])1< .~mg_k­z-QH{]C=P(0ii-DmLJų}Ihi#ANBHѥTR)#OF[h0k)@QFfgKTK*ViƓŽzycO1jh{Қ?}1@&Ud,@ߐomϊ~xڨkEӎV= xC4Bh65͢|S'yd]f$ZMAoa*cu-e-,9'{AjVUs#!w³]ϘݹH0* RR'v {zBW)9k*ZkTN%o-7~Оq{dgw&&M*&.oe#VPp*f+o?dA>NWM!ddbO?i?GX{Hi^@4(|qI.s&Z+J^-65xׁ}4,@z$J(x tOX%{;NHiG/z 1*UDUMgMG: I8譗1D6$쭅#lC1@Շ J] a4y5,H oi7Ukz3Zϕ1޿Z$KQj+Gѝs[W3逶L8*EϫapМ\c$1YkIcWM HD,kC݃ nQMİsUll`J#<0E6,EдÐq7v3prN H[ y]!N!%†G"P.cW`R(Dt(M.AƄt[?~IbX:d &LHu??jRaM1B%܊injW,$Bd.ʼnʖ9B7[((j0~쥱y#(&[%ऍXʧ/9T9ogQRDD HڣTO75Sq|'B_6ÁuGZGJFKUA2!sb&Hj<EvzZqX1e"I@TиA/1Q _"@V1[bURaoC;%܊F漟9)/⃳Ի'*Zpj:W3^o)e-4CO7r/$hvsB8yڧyKSYhPlYiL~c⃊0w$"|ͰpQ+f򒑮mxPm"d ͢Hj<EvzZqX1e"ID1p= ^bA:E8?&[Nn~woT_[a#&"Q'| pb4{?Q豸d.Xk.ӏ]ZvNrĵWBFk0~쥱y"XiHy&[%ऍh^/9Ty T5QT6 jD1h ZȩSjqЦ;(jOFM ȒH& H56 nSnw GEҙ/XNK6ApKt\*j[:'n7MJǃx?օ!MIд{\\:]H~'#} 4-lnkhZM>\RՉzR\]cSZ+O!xz( q7<3a-Tpvn߰oq83 Oha1r{Zj^,܌=^5?3$c++5 RuvH@BXJq yǹEq"j㊅RzOpS샤1]C:1H$bxnT@+xjO_W&.Ob^5Ԅah* [izz7N\ůGD)]4B&K>YWni 9e恢lsRSDCJGJIPMPOTQ%(t\^-oC䧓up7/ K̈́' 08YsCyQ3,ؓae㦨I)vxŤ' I6oJ,x16YPm0jKCy/X_+Give o65ɾE o箢WxErIfͶ=˄3GqݧPͣ_c`ۨeS;k) ֪^YE8dQaIt*"fxxmV<mƴ3*jnAY}DKz7*ImAc%ŀNa;_〟?X)YAwV2[ h@xf>[)uE DGQw@c` cl 292Q*rleOȑzpMQ1vKrE[Z{ q`PSmF;3c(C*;)o' ygܳH-{BGcPZlUMatjaO7M@GҤ`{Lp|<6w8Utl#{Eymw2]T 4b+bZcNp*38d3<9bguK-WSt?#Csx*E8P웙$GTѣD֗nRA,yϽxZMk{+|DzHixw Qk_RRVX,QVL;}(ӫ>YSN]kEg5^Á鎗nf)~>]VQH`xD=$:EbwOm#|mD2cХt#\E-H,>X^b[Iغ닎#V5uGO亨6b*;Ҿ7R4+"g'섷^©;1]oT*wMMsIMU4saF]sa~a=&F[U8YYIܢU” |EmLtELtLt p07ڡLj^e.4v M)@f)CM= ܮT"HT\1Rl|f.]U5nY/I#FL~:xYF!Iغ닋m.>5uGO亨6b)|o&iM9-=VD_B[]/aTMVqBŐxؿ7p;& ݎi/𩪦[61U;b|!LkqgN)}VVmRQH)1E:G3xZ)/'Tyhn(AsHh6 xD=$zncx3=rP!RepǡJqWhչf $3܆'&;b.ޫA㴻Xm? XoUyK)M +Kqk* ;ކB{D(cKh*j~ * }pօkqgN)}5ef&Cks.*eG¬.o2/:ID^nWϹ3a@֚mQxl p0Ck-BX,z|8r]k!Z$S 06kjziP?laۯZz䚭hr]Gd\;P~.yejs tMO|nÝ }(bGkS!TerJ'J~;ńlBLnԪ "@^Q蕑1U-#7G,W.#n1o* eCu6$ʋ9y7pQ^d[ib9E;tHBq,.TUQ4Ee F޸9C='tR&l&4#zF < :յ_ $Gֈ}AېZ5LrvGݣt}(w1~T-bMEk?y_>2џE0]Z@glnU~5Fᩡ#ޖzN6@*\+*j4rrrrI<>JOćI \\˦TѼB\KȜ, 8ey-J%>zO䴾a 48XrXQn&5{DKJ8k= x0eAYbW=yYױ0iׇOc'o3g߁B-q 2m.t[D%ҕ+V&b"GghܖeRSG IdO>x^hDɃe$ҙ,Kı,KFD$_J=BOuS]YsS922 ᗙ1,oCG.PvAj QXƼ0:DC}g"ӍAp8":zMZw;ъPy{Xm)o v/MAq@l #JrBmՎ=OЗ2]#R3v@),90BrKkʊYY3U69|͡{.c\P!%Yb!%M+ )D;`g7ۻ&%]s * ;I@$@Hn&f)WkVaT 8(SDŽg=a iMZ$Ս}VN̓Qao  -Ù3UOD8m.ĮCePο^9Qd|:֊[oE8|d'\ОWcJ&AHIcb1׉fW9d}†uz."ثꯕ!s%$پ (.PWnW~˞By0֩zie˖6MLXV:iiLc^MRkOm{SEMQ1,L yd!E-$ B13jr%%-pه[Uxl*Z'g&y>tξ?GQ^%ɿ.S! ٫55 gqc!:+5$;!a9a]Ю[$6~#kĤf@ [#ٜ #DNiԾ[rlϭ ɀMij-s-VMh 0q@% (ƲA4rcnH_n(|#kڜr!Ml,?RԐ:q "oG:q`kîW0t|ʾ͘U$",$q'ʩ)?3/G*{o)KTȤ(kf Mf=Y{4錄 -MIŅ)X594UwB alڴASa-lfp \`'4IREdMl_ËϦr3!KjÝi1ʌUݑOyldD@zjHІ87z8m2K[îW0t|ʾM3 hN+yVORMnREdMkx0ތ!?2*PL4!2bѵaΎi4F*n'f m!i&XC~ t!!B$M^N?b~ۀ%-puyIfUxl*oqiUSޗ*JmOzGQ^,kjL?ɧ&Dm %#S:P VSGٵrZ c @#YS<]VU[_(*mcnޫ<ߔN31MJ!aJ"-V0{~ rDJ],f|_JG-r薛=.z;c(*&*­2o:Iյַott{9[TW fǻݰ~{7LلiEkU! _M*Uuo[ePa_WAB ~7WQ__վ[탓$QҐB[Jg[+ܱ㴊tȳYe[S+q>&"iK ŏ1'ūmj"ƩiB\'(8!@ pކUg}"w.AM#c/@XYaIaH!M-0Ee#J!6gDև.-oQ\2e ~=؞ng6 -TB9JB%%̄^vTG׏.$Z FL!$HINh!Q{NP}{ \ N!W+d7pմ4V}>KqW0߭Fjˢ۬&Bl7s4)CHŖH2I?-r"ŔgHⱦwt$K͔˪o^VںáFs/E{p`+];5źDf I%|Ymj ȿw-j=$)IİY};/d0#xMU|hXTCͧ4T5RE8U_9AK\=deisFv0n/hų7oqkm_MCͺ<x].8n T 84IŦH?K?nU\0kLge#i 3e2(Y{1;նQK^mjH^xvX?JWzlKϗ@(Ai3On-R/"RZxd9zծ{$#p)8 /f_/@tmL*{r&_G= r_(z6k zrpZ 7=sYmaV`ƣ;7}ln=mqO_ܕ|\fn35Fjˢ۬&B tR VTIbEI  f iL:Du3"_tfe7 /pF'z 3icI Ѹ~ݾ ]]bnJ r/n49_Ok2"!R%.NէA9ZIR>=a /gzedFDgvR/#_fP/=T5REU?c3,k v0cQ _c͏6OX;]|\RNϪdH/4# <HȿR"& /Q .%M%5R":svDsE^7|IL=_Ob]Xg6Λ 5]ꗒTDC*:@ ¬%iA LUрUvʅԃd5PR 浳VI{yΕ}ĕ|3'!rDߘ82"0s3h&oZ!Wfp!16ּKp>r{>p1 (D8FZE%AhH8AcDa PD*fH3)fp (TQ@:HT%~)&$iVrBι/QI)vAUC6PQ[Ub*g)(ypFN܅Ջ^ gp!QGN_x)$W*wqNyڬ-]<+˱WgIȝK3Mb2E>-)n˛OϿ^ͫ-޶2/YtGk12MJ}$${miMYBӓ{/Znܝ`\Ob'3#RT[T(Dn`BӸjGwjaqR{!HjO< .Vm8J]BB=*a_X`.fK^0b{>w+}M:2}t!s[-FܻM ѿ yDHfF_:dZ3%+cUWP\U+'6}r{ܣ@˽4]scQnHXoENo^Q51TA<0 '`%C+Qp-WEʔ^. EF iϥ:QAPYwsޕ[ L߅].çƩLI4kBr*ىU#H AJ52z [ŸЮ @w98yZy͚&H8/ZB(RK1-ƹi>8`?FS9^@L@N<~Ɉ4tי:g Ii#6oߢd; pC#L/2Y91mi*f5QlMp%WkkU^tiA \oMizLCz@tĬtJ$rf+G1ظ#CmL ̓ .oJVY'Oȭ<ߊ}|ڈ|d My~uﵵ'ua?$Zw+,͗^[=o_:DpMW,o^ׯ EhԮQM_owA-$E}]eQXz| 1NR!2=Swy5%!ID"U/h×ɉJwS[y{\brt*R`}$&GzʥMMZaE BTJ-.htRA +%mL&B*gy~LJ?3HWS~3Y~-d=J#XT+%5Rd^/KeҕZ݊NȒU'X;5Z(WS[ #_']:Z՗FX55"br6}O57'#-ti-HY1g }S]I:ͫ)Yh->zx!aܺBZkr+r1,R(s߯o7v!#TU&ع }F1T7RLk3Y"-%| thMI/̬)DՆ$ gL-8{/gﯪ.[TF{>F3 TSU _˺RU}$NvkeRT[ B-#G+TKS/̍fW}(}aSjUtx-JUjbS$wcʥj`TVrG̏!~V=tuebx׺ԊG}'nQJٗ=,zV6ѤR%!^@t+bWVC [+F.r.b?N*5W䞈M@33IQHQ**'Wq]'B1}"k~J4hr}KrGg5 HbR!wCo.-?3qoWv필4_yg+ Fw bD ku v@$#Ӯd}%~]ezkY)LЯGO!gW^[ȗF%U1+i0V9QQ_\ͤOjl+4_K3 \pB}h -ijgӡ k;q9ѿZf4`9%hCWtƂ3D|]YM:r">-2:ajo6|O $[H0wI(nUIYc~PѵʹPt#ZSW:֘9b?tckUlwRW\$wj֚jqqEUrIĊC*S@.}y+2d_>eS^.p٩6G%|Af׶:ahD4}Dm&$tF2F\^Oqz pz\:Y2nV ZE" |nDY"[L@s&"ueY Qh=Ziݵ2@H|oܼJU׵nߺ3udnIÉW4"Xn0?Dy*uN%ʵH[Pszt]~Q}KŴ(i9}[ȠY CΩD|}QzlYG41-k֫aeRV5QI3qcYvS,ciBiF("'֢/]?si aj7#AKsl[(Tu~̌ॺV܍.  x9^:1)ˠdzy8FVL62#VkEDmT1^Ԛs֌I\%lKrŷya#Ck$P[T̡EfSH#GfZ/ȤoMe~΄`&Q]T ~RmCGՎ6Ꮢaֹfkȉz+Fs|ș>ɟ1Sk(ؙ(i[e,Qʚ~I&WfXȾL_L Cnb*!f:$eFU<7 2]V^V(pCW(BA]`1[wD?U= sSjh#-Ptg0^jt^B\X̮!.*]r@<)p!!1)ۥt `/Z68BQR8f;bo5kU<ӦHtGΗ Z,6_/ oEJ18!r}.ğ"J׋Q8NTyG t5ӄ:)J)KWrfO-6SPho;ՙ%LMCfBG;Dy=ʄ)IUY THKr2)t#H4$S~fδѶ'G-=^̰A߀V5qLq1yA “n$rd$t J5Jvz'RUmvVΧR#ga})GV߄m0ykn#ѭ z;B+8 圽##[hnƅִeI2,}%+Ęhۼ뒝Y>*Ϊb3KP B!۠NF&cMQi=n7 ^E`N!azG,)uH9MZ ܁OX]b{_0iEU#Fر&(Տ GVt:M H;S Kwgz s9(Oc+y<utuXk'} )i^1m1Φ5a|] NnIրִM05ܑ&ip_: @j):p6*#c Cy-j^*bC+N$'E$څ[QzV}T8䊕dQ+]"(6|Q_`+88V(tR RΦ(DX[H Qr[B6]"֡-AGF|Ŀ`:yߝ&C iπERAéՇHP 9)e`1Vx'? Nh_iְ趘SCt.,t?=$@kZ&HiӍ6Qj7oF*nň-PrZJI8S޶J)X"F6^uT->eU'71 n7ӓ#H4I`6 y_5We,JIFv1OU"KӡL:M"P#`w(߱%Zo8K98fIE]/a 0\Wbmo;0PGfuoJ! +]6}043H6W~\ k WDvLRɍ_VD񪋄n?W&G1۝|MM]UXUZee| 3>'oc$v8p6"EXк'l™-\3簪/V+>8vsw("v6"6:+Z h+ #_ I}>E;!G7tCE@ϛh>O@p/|3h=fj ;AtgaCVT?-dQz/ڤ=\rFtGU"HR)GNgyu Lx}GeԦP2&fq!Z/dT$}-r>*Nb" عas, b(Q!*?M#i*`K{ӥA3IΔȏƴQZLBȤéG*B#+hĺQ텴=e Lj诫C5yw:қ;e_Z"f]x ]Z 'JQ\ CzS;B(>%⸮+⸩Qjıe4mRsxRQ2 <3D HA4'nRޚCp =Ww=zy5}"ACS LF,GbeutWT=G;҂ SF243TG\oEuD44vFg.D}Wc?"Ì;lgqV}mJ%,[7‘ FM6j &Ta&ڑ~Fؖ,IƊk8Er/^DDװ8NiRQvU Gcdo %6ъogbdkWP%|lc/9`B) VVYmItWq/u< xj,S_1hUVN(TrseIKE[b[X-0$ cluLYjK^ U”yp,9ijšWwc04"3EpUmV!s Oi C9v=&-Fؖ,kUgȮ@KȀvd*6{3J}]ʏ+,&d=<1ܲPh@'qR{%[_nVFoٚqI>l,҉<0ܴN1xrH>Z d,|U0Œ#|D ?fJʚ-^8?4nƆcbWt7]=[Fw˄`-WfVҼ[YXX1=ZH+VO]Zlz5bXMF[Vqi _$ *Uv{*<԰>z3trAQiJUvY}Uvfv,M҆++,O# 7-29Gg.IVbhdSk!fQdoagRRpدkmc{58ҀcluLYjĺ{U”yp,[NӚ_jšWwc04#?yiW h®a$K'jbPcrj6REeZd` 9|ˤFJ`|ږQT2gD)>v&FxWWʜ:`B) VVYmiDGN^rk˳n$@bȰȧB.0.-x9x9-^8:ֶCJ1e!ף;Vs Qk~)cY8`iDg-0!EX1=ripd&P @Ahj5R)E#Q 3Q0ShԡSFeA@#A"&f[,gmSC#)0O==n"[w*3Z"a(o)9+O1Jej_9tBއ9Gn cn:*<o CjG6n'8AtsgBJ?)+ G' fsb-dZ'.%ha"AdY: ]MMHNj5RX%!o>H  ˢP鵂(31#BEHD)=2躯%<έvyP.ghmla0r-5InLIg!=g˟{F.ҡR_V$ ͈_oʄ:> }!j~=7ٞ ͇ca67*1HW&'~1Lnf2{+ݗV/'*CW ϖޫ,,ґm]WP"^ fc##o@OW ԄO|}/ ϖ%~Λ+&!S e:ؐOb4!Ud(J؋lat*5% -rx]/Ѿo,&:?Z ١ī?T告1nw;|jy֠G|:@"P|Qvΐ ;b*9;;;# Wvor1!+i"v p |^X7 '%{|/+Ǭ ,9 ;q}iDzBˌjiU/$ny,EY]2W>[DԺ݁? qٝ~}oTUAX6mkR.^WIf#E vX< R{,V_ւ.(\f:Jo9ߤJ31OXʾKSoJ|j쁕`˳\tjQC2Uʜ'8sfm[p9AհB!o$mN=竩QFP*#ŨA4sߵb_(lLceKU]`qH@z(uKCHV{壣R]%)lr3>w,56R^5CKimrFN(u6gmS5^ IJ*Z[*υ=k%5kwd:*sam0PSm\t̍]%:&D_W;&e_$V>n^׺ݛW{"&Qn0UR%/O!>z¾g,fE Hk?w${PM qHSƃZ"6p>DCUZyZ!\*Kn,&^fVETv" ?VBQ8{PvGUG4(X g=Ye dlDyZNOSs纞r%Ovg@1} ZUq$鐘~CE:g;;xѿ'7Ij87'^oKv+ϖ1umJ6Ok ~ܧd+s-k_KNًdVv0GX۱)M"~qwET02BYR}c ;)}%g j7([VP]W#LBuJW'T;8m-ZTo[\\z[maRe+Rj((іN4m=(.[$mOVI ybcK˻u:+zʨ _KVeҘII]vd3sU-z4/GJh"iЭ?ih+R:ϥQ@n^io^lF7X,tc*c9,)h{lom̺Je& iL=KCqYhLlWU]n~r[^HV%PR!`9O9R]DR1lr_@bQ_z^ɺB;>Z!@FBP3eOB -K%K[FuSղC^q-mq5Ί޲*Wյ|tc'5z]1L}e^Dj2{qJ;4zMj]V}Jh"M+ roޓ3>23ϭb~ٗI[> M҂ ǢxbP[\̇XMiMʸֽ@$GRʇ"&=w_N\%ch( :\ a m~[}2Yօ6c_QE%}0_J` ļw_93'[ݕ%I-dԭ-zH,i7~#rIcR&R<8~ű^r4wa8!aݒG{ƹtVΦP[㜴$af9^@0} `״hЮ`̏ q9{|Ҝ3r|2)d^g/kGL>H'qĜg*yJ_Ji8"k 6w#VݶᄀSpH3> 2qУ`1C5,l[yp7WUM'0jڥ6#Kc:A~@۔k6oZj<7a[Z[{9{;֎^)J0Hq<Ԥiɂm_ `inq/?4$J_<ڮdL&eKUǿcr- ^lR %:3T2K\W[9L&Dx`4dGo{Ο#IV+wåvK̡$ m:%H!6iy,OœMobh7Z^Zd3Q`%]qtHo ׂ]-DN-7ş_sfKlЙuǙ.,TەWFsչPEW~*; FZ2F Rݮj.1UVfi|$o[ޑNgr]{oe DV287Ş3mɘkql S# >EY%TϙnpU6dqHWBL҄5dB[dq*;^s`{Jc!X|)LϏf:Z#m;.Y=<| C6!3uuZJkivT1v<~b9\J8GTeMe?iG_Y+#"Ts{l[n]4(@u }e ۟I>SgH||6JC2w=15y9Aup\ x`Dx.s3~qJ ^ݞ(!js(b#Ml,_,Љ% ނ!G p f X}" p3 唙U *b%ZV՚#3zeCaZXZE~ef]-JZ0N0^!?qE5:{&#bP&zL3+akr8+/@9RV)vp{SʮE뭒>""i6\/q 9sc;j[%*Y]~0CpA]"ږi\Ck+{JuUU^7ޭQO:V7S^< ڮdU+Kb\i p5\ꡡx[py #qSS?B}/9|Ѷ5bZ5nOy&}iMKX}[(R P'vibv;N@.T;ƥ\KFOpk!q{eh K 7?2{晴O(d4R|_UT_`J1}z;fzi*o*s$x"gɘTQPop\ +)iN-:gaD%!f?AK>\m9bC%kNAzm:b`p.~#\=/& F#=q10KK'i_Y0noiaO&Ez.lV[< icw.񫹵&rU<~D,g(V_ FջZ,)˲{&=T5 |c"(qTvߨʹ16Ă^Oe5D]KW" Q=V#+NNmL&gs4nu[oXuh< e$->Y>&&d:w>( &<"JZZp`M:*m/܆ ,ql9e땉Z>0lpL02H^É-,Gb%~Z`VqL,UB<OH||޽˛Yl/-ܹƮԗ/dMNvymkkEL]#0!"ʅ>+R/ZҏWOGmʹ16EWUZE_ƯC |!N{0h\6%jqZpsnfq3;!5F~ׯGo$]5^J0sB>&&d:w>( &<"WT8nUK[4хi=4B. )3S.6㥚/} 6 Nŵ:eeh k:q2& F#=zc$~ɋ_Y0V t&Ez.me7rX7q&佑6O:-J|)Vh؆<ʆ\= }V^=*QhbmN(l+٢Ýv3n&K0ݴfgs4n֝Z^%# U(<9@<6~B*2hp7'  ȠrR4ђӳk'KbB\r<&cy%t_El&^mιXu?I6iv0$Y t{t3~ޞ甽xFΐRfASsӬ]>U+UպC7NzP2tIr=JXGh>jiJ{Kskjm'Z.{}bo&4sV >HTQ3!>n{6)AZ b mѴѿnޝvO&QD%9Ʌ(2Q2vk^++7]Ţŧf nT q +3y *qtcZ߰i@HN~j3jVډn# 2w3Ws"㝨ص3Nzܙ;O iv%=3[*=~hq.rtu:/ݥEIr&j+KYMܲk}xjdkW֏hSREY; XS*bf§4ZaIZnq 1Te7[`n4߼O*&P%8d2Vd9p× 3)!h6:TDm=gQrھSdMtϭQP3Kcz=?KK,UEYmY5l>Yv *[Ke:߭:zfR 1EXy.tw݇#V'[uK?1Cn N;GĺĶ1I?$ZփϣJQD/it7Bꆛ;TSS®',?d\d!m""zGO%M*r VmD\ }LxT LaI!@?"l0`|'M1}Bul `3DRdH=xЙц\&jִ&%cO&0Fvi=>R1r1wKntJT9Ȏ&%EgJZ t۰Dyrk ?x0l9WR)E"HYhnN`Kf[LJrߓx 3/ [I C/961\BHԻC] I{HôĬ6tp.:]ưU t]_K L( }f8atDFyfm3(ZdZЧ=U ʇVJWRD( ԵcK(c3QbJ*5*Bb">"\RuʛE@Vy H,ɰKΒ"XTX_l7F'lT$rgXC ı ]8|1*NWvʁ+EV>R6"n*y=ތF g?ui1F>k\(…LXRUԆR5& k;7#Nҋt3=Kfen)t4'ևI-#8B񕈪[ :ܕIփ AEE 6l߭[iŗ|/3iOamrZeI9-^ڊx3i!S^u9)Ũs4S7d`PӏӢ+{Ο)MZ3KŌތF g?ui F>k\(…LXMW걔$tP瞌Whc#Nҋt33԰Qfen)t4'ևI8鴢8B񕈪[bܕIփ AES͝Qi-aWb+2nm 'wID* VRM>7uD25JW1txO4OcI RUcb HpGWwNh|(1j,WvF 8 X:.- \f)xј(r] *68Lw7IZUe3I*ꁧ=`^ ߗ)%vM^-3԰Qfen)t4'h~Ԓ8鴢8B񕘊ŲܕIփ DZdS͝Qi-aWb+2kf'wID* мRY5Y6iwyDKmZuBMp p}fY؉qA@4Gxomr+HY1Rԣtw[ Wطnav~< ;zw-+3 vsȼE@50`oxOc"V5 lsO(r^jL'HZ_yF.%:fvu٬\|[QpPG/쾩mʦh G [\QS!BQiO@m oFucjL*<L_%ހB"ψ6.NO}-)MbBdh}xlK-`Ȇ=f33o ҮF6ͫĎ_`Vk)N`j0NF*0&ѥhT\HEwA e^_<ĴbT!A3C$3O%`g Jx'㘪%}s90n9ޛeu44fqkrMSl[DBk4O$7\xr|K)G+vײ29mkU.huiZ$ NޖڪC =Dށ}4CrB9Iۭ,WRIL;$Z#$~U˂Y!gfȥa -Tݚ հs0]XJ#A@ WH'v[RO-2%|-u/uxQe4<Ҙq-G:ʕ7vy($u|$ C>YY;'-Yd%z=rA)O޲Q zFarB ώMjtG[%B+*!6ZƸ2Mbp1J%C TR^C4e_K}/++m|F`A4tc]u^Բ3 -)t1R/WOu-uI.@8_t|Hظx-dz.yNgrǶj=ԉvOn>qWAMfpW%[=8 ^ l_YVіdĪfu'bBcJT{xiP_MToJS@n`ׅIh7WWv$^5M(&Bi}/%_Q5p Wd,7 M`2#]j"Jd4m"ΔDSձ~Lڝ\#+Q7=Or/bU Y7|ӲYܥ?Y=ԉvOn>tS[8+Ef9* ^ ͅ/_dBhO2bU3Ed嫩;!1_%*=b4/jzgeG)]} 70Uhxn`ȴXśЏ'!史~*ZHW) %_K}DU($3 d[㝀C ̊(52qngJ"IǩjP ؗsTiJzqeE>i,]=ԉvh&L=%ߘ%[Ӂ9_\tHD5aEV@~[;!1_%*=bJjzgeG)]}Ҟshxn`ȴXYg]Ke*h)@93D_K}/)֫sQw11#86W>dKKx 8[H$>Zѽ7NZp6$"dCFƫAo=;_>G?%wG~^w[R%r"ۏD2$٨rU=8善іdJhRv,BcJGFFS)˯= 1υTz>κWvגOTR5YeYeXZ%fD3B{oox fޙ9V JQ'>Y毧g"1:02AnD&cv6iEN"[a-$I[ycG"k(g{#Ճ_JJVMjyt y)O6F (T93z7[eQuK9${OVib?] sQ*.*̰ *?hSBp "aت37>W4mfqU *sjLlZ]LgzPWcm?TpbEhRVT\>X.Td$f&k Xlܾ/(Jki4Ҫhlo]/GmR> c^dKi?E̬,`1#de(=Q&FVT07}? NPHjl7/-ywr*5 U5UN5}ƼY,pɾ+Bkzi q5D- bV$ffbT*jTէ OsNo":Q/zlC%>j LpsqUvd2!{gg&tBt0*63w]weoX$㰻rv5F0Ġhw8Kdc4ceZ՚ooePSD(9t@E2Ƭ\PlZdt /ȹE Kn?5sGkK1K_(Nz#;A_\ÆU]N\$7^Q_UnllYR4Udܷ__tC_]/Β搟Y )+kzkm tV"6=\Rz4Fr ~ho8wnڳCMgbN }uVCMlJeihd6ҫ#4^hλuL3[_m3CQVU@!c (hv)gVibKIQD~ 6Gy;pupʫIzˑTK"Zb*Flk2_%!?ׅ9eoMm1Od鮬]޲ַǡ[JP"dˆaۣE9w-u۩6sؓ$*W]cjg)դ[:ҎmN4VJy]2Z8wfꙟ5f`r>,(pgxU:XBՔt.٘4q `0tpX+ZS^_ʂ}-t]2]2rXՙ`@ص:<"Ha)mj3cgVibKIQD~ 1o4wupʫIzˑT%*Fl ێ2_%!?ׅ9elt3Od鮬]޲ַǡ뛿M"dˆaۣEލǂ-u۩6sj;O$*W]cjg)դ5(mN4VJy]Ѿ-=,ro0Ӫ[ue=zxn$`\0Lܮ9 1BzL`RmTvxd!2Mq 1b`6݈,sI,w.ӊ^p(Y(T N- [wOK_g|rB|띬t9k d+˵hY)QZNTr_(w'I$gZ(gGCFOڐk<5ͼ3FKNx[|/.5,)K蕜'sQџؓh۸E=SFTio$< Va`qEtBXIsv#W]B=rR) 88/]f!Y<㇛\s6i~JQ{k|zH'.DM:rAFZG ^3؝zOwر8 U).ږUIi/XK}7}cբ|tϮXsX.٩6ŎH6oNoa?"'jºi IDE^LR;4#% lFz1$#v]CͦPl&g!Pʅ 5Nw$Y]нP`e:T'sS»S8yk p rd.HqƆҤʇ!4Fd* Q/s%:{EZviT))GAJR$>-x)_yۺuvvmH]Tw5j)O#RRU//nu b)+EnO.,&wSΉkgsd~,[㔬4W .IL膖/ H(U|@y/}E)eQKT _K(2бYh4NnKRo<ͿeGJ bE@u}t M%~IzCas-7A K;,WTL |`&vdR{cS snُB'21bxh'͞P!*!TV쳲[5/HP)(xy8h @n,LmػM14J "Uʖ4: o!`k?c+O)\>Go7$dt$`'ЋF Dy'OMy6>κqu6I'U}ʏگjit]#ggQQT-Ŵ9v6 BVm[Wm_B0ɟ,UZ޺ZjJґmq*e. *JkU2 yJ=I XK; <^p!Ig{JGU2ayL%'5Ջ,JC#(DPJBY(xh! Dp{pY^.SLdI  ^K)GjqSbC(U]̵}w2>-by +#Y8F^5xXZ"wZ?}ZkϹ_uӋ~\r[JI?dJT|f{V3NU &k;:gRo.-CrQ@\jڼo/!tLd 7lʪן{vfZR->5T˄]@|I^jR)G_Aث6޾ gz@NBþ`ӻHJb iKR}M]XĮD? #(D2$MF ̨#!d\8c1X4"B6lfYW*[T>%P7컛kGˆ19ߊ8WHD:21+#T)/e,rׅ$^}ͪn;:tڒQ"tq]테Ӯ軮GT,2UJ*ˋhrmG_pHJںk+zȿą_34ݳ*ֹ:x|9t[p.}"ƿp/ʒL\(ο'փeV:m|x%}Cwb\I1/I7ub+『>2đ6M 2#NiA#dWʹRڦU1 -2XAeܬ] 1ZxdLS#HUhEWZ"wZ?}ZkϹUg]8|tRJ?RcmWc4.U &k;:Vu(CrQ@\j붭޶/!tLd 7qf&k.׀WÙ+WJEp.}-k p/+/LHR|}h6[c}|RXw9wlQE% ~RS|g]x1ߌRJDfTD=HdQV!ځ5@='mumIaCW"H&SY91B[AYzjuKԻbv ΠoԥS~hw5W`r|w6FaҰ镈*EFZJ}Omc[/N\[+]1}m+:ޢ-_eVSX>{nii .x~ӗY]k ᡺  ށ Y.eWl>SXVew ,w=_LcuS/R`T3}8J0yE A;ƒQ+L }&Y ZH1-htP z`n#{\9t!,$ hBh?GEg,hrvm" 3J%GK잎 l.Jg2NPn -iPWH3vq6p1G^ʉ{6G<ɉN'w1?,?z ';31-K΋v5T E? T~=G{;%VMj)mU/!sot*\ov N9ri !qE$ }Y~ZoVI ~GΛ/o[wzD8Ě̯S~8tHw70Sl?]Xq- ȟ0X޷-C^J3Bc*mspD*U?JiX̨=?Kmj=^W,KӦЛJJvdzYaeVi=V63aV`-1UK#LwQΌ^jF >H) z"mƕ5h&J4ʆiGS;먿"RM6,*nQ5/y1@WjcMey^J+r`c౭@CYǍGp,˜忪SȳC> =Q-KzĄ9NҔu:ska$Q&!a @-eU5tO\C/ѶjHY (8 2$!;Ak鸷*-YAR&ȚWŪ&oz 5`&I _,'ו_L-S? -c=YXۍy)5]9!}%c]*ѨSLvE=5T,VPT %_+I cX-ɁH9sDW=`$KTO9/|)u\|OQ`,msI|`c-6 5YWiYU@9J XOdk L:U?>_R7_7U Y&5 nz^Wʏ*<3N$tҴ$ S>eP9@qѼ-b j}hBeW4•^w/úP=T9B#|r,l]~ 3S派^`ҠO+Rzzβo#ifvMl$]%bO }n35YTDY"=1)M,AUKRYOy[7V }w:Aڛt!m6#h]kS;nZC5ma҄F#5~߷߷~C}$@F;3 UopY.}`-@{\@*2ll<=&+e` )vpO>r`++tM6m4]_}Y#v,э$rzr.(e#Kqq@M"7Kv{Ue E/2S)QgWޢf,{km΋4|.[E_Yj%3`qSV7(T/˔s"WW%h@w[m;Oz^bX4d`:Ƃ3@UbRmⵢ^Q ![n53 H *ŢR/sG6aw n+|DWp1]g C{nv5:go6$`O7!N+u'D)ÍESF(P^J-#SE)LH7nG9iՍCa+sEkjUZnȵDæP@(n]?e*q\g}Ʒ-rܷ-!4$.I 5TY ierf0U 'ZE!ʡ$(Bm'@>7+TA ћ1 xl8LC}k,tnr߿R;lB83fY|Y}ACsĻ_VӬ~'< G j:MXwcF8Ɣ|}{ca'X -7ܙ G8s[$ڋb"-18HuԵ+ͪEܨ:ZTa5~F5vAzPl6Ô-hs Mc%i;s(S86VӫVweYf uAݞЋy_Eqef@#z%H.qe~b}рab j+QRJ 9 PZJ_oo]nn%@^r{|lk0ӣɀ/P&!- 0Eʄn\_%~(%Hyyd1 |qJ"^7.^$Ql`$mnD7: m/% Jz˼c(wjoR0E _P֫/l#Lv0wQ=O,zNcS :@[+X P5T^q}P;mrXQPX&gNӴ]v۷"$wҟQ'Ab}@haWb:"}U,+ЊYE&ӎjifڪ4©RA #֐Qԧz=T'Qh :uY摳/|׆1Xb:9r#ZeLtR $Ѹy@mm'xTҚX2Y?9J< yXUp0שJb73U_̓~7OwHrD4J~'iػv۷nݹ" <V=?_dpr>PFj0 5qz#'R¹Q#fiPc43mUfKUT}"0RVQԧz=T']l:uY摳/|}xjv1Xb:9F6eLtu/$Ѹy@ZЏm'xTқ%*Y?9J< j%B!x#%!RjU7~~wM$NqSOpv۷nݻ<4D,d#?p+tlDdD \^xT7Bl"m8fmӊNSHLk:u)ޏU8hE[,25N`VylԽi^݁gE}ʹW&,}V+\.:8\7?Z  o4eR[|'58xpCEA*Jo;Tgrw#G;˒'8H|o=}&NwD߭,2KgK:A} [Tp^W"9Y畹*XHJd1 NJ)^@z(9Sf&%6^nkA.SѻTu)r-( z H$#bt!c*ܻ#л|M=h#$*JR\-jڴ}DW7ueݢ^FSL]we"-X庬y,3wM6uq2j#. vm'˥r7g # wISPgiv'd{F 6AaOĉ7p]wranofk88KG D W<{@`x \0ʝͫqfQcM*F3/>JA %r4yR UU9h@x) ѳ]o?Y#kav6یfZI,vOrZVpBy4ttΥy_7$V\=ovl֋:6PIP4~xEW߉<զ;K-7lt2RUYC[ڽH9dѤ& ,oznWEb,A řlj>Vr?dkQY:$kܝkֺZ\'\"{  ?C&ËMf/-̠"O0I i _:i,L;1+e/N}?PHtc"Mҳac:^Q7n_y;[JOuaPEu-N5Ԝ)A 30,zge_= gRXW[ix ( ||̽|X,EI,X ˮZ˭&q*6*Fk;rm٘Q5ӥ@';$oAuqiX8SVWnv2GRK)%'4椮6~5#'0q>اQ;WOY5Y6S Uɫ)F .Ȃ֒F]q_m nΟ2B2 W4hXՁRKaƾD(̸)I)Br o= $htlRfw RZ76~ú?O~[wg.Q5qг5ws`U7WQ$iJL]VhU|C,h`@IUUynbq5JJ#CG#y4^ +4Báp," "r Dʰg*+{" P$Ψ#!O`1Id+*IH!sN(#3!"TD\U2 0o1w9"K;zX^X+_A)VJ)_ީ^z2CKɝx`7YAԟї7LBop41-agj̜sr}h1n͞^ PnĜ,[ =yF;'21GCdV^{޼CCڝkyP7b)C4w=n>|,caO"LsR8.T@0fmr#މI2xG$Y(@wpaVׁM;ǩ;߂\:x O y ,^ 6W8>3'E"6W )eLnЌdSx,SL O2XC<mkϚAVK!սvj6zm0iSFôlvZ )IoEII侫)n:C[~HkFt͒MLʚԩ6@dIkS KBqaN6Jew^nywko. ol9eLZ״'XulXY !%AgtGa`-6^]\ؔF֊.EnΧN@q|+vog>nFRR9P/] '7hn4(;oFmHm_V$\Pqe/A\C !PZ 憋Szvl/[DM4D+vIU7=**jJJ(hQb_N׭+eg߶%?Trɂ£,-jSr2V. "ɪ0ڨK݁]V}cU*)M9);LR*+zM;X (g:ornT[JF\@0tXatEgqqa`[ڐ s> F:l +/S,` o%,5Cr_(SFZc>Ki*5i'ݙUV(I A[ CB' [\iHm ^бwޒ|UWypEWso'<BdN%91ykĵA8,ssF)Nܴ- SI[2o:lbǺk%TV AU~<cV֞ӱ+~wp֢1yC-Lgپ hkQqo>M%%v^ 1%0͏Cgٯ}{BAJKTa+%& ;/`_Pf/?tl`Rl..2d$D2Jdw[Ҏ~!Q] (VoXE@T'ʩF, V=zkYCHmێocNs[@R1=AӋ{=&%`~^\[{&~MSuM511Ɖ%8gNg63/4_lO-<ٴā"ERN:eTJL`܈t {^l%LT ӳ\ʆⷼi?:~h>~3 DOE;cw)R!^.;߉r:bdCF'4LGrH03(̽pC{43T3br1aN-V.=]y)N2Hr/% P;_(0H!D,dwcNx.2!Z(>&z5Ĥa69ME԰Kr.ބO$`武7"|l,<õ(}gaӱ:IdF.eJR.ⅅ-%I~MZEumU;7vݴV." ~SzĦ(CV|FvbZS丆Xl~$"'= 3$$=%fy%~ &ؒhخp46r,rfg/|,42if m5ZU/Ob IvL= Ps6 y23?L(ګZO\oۗ=UTDX_Åݟ)%Ԕ)yY?,D|m$مaWj~V.rR]R*e 1cn~I6L뒟 H<ԅ'z)Z粼5f1.J_ҿ)K(ʛ%!Q &PMfx&b|M}2߈y ڋؑM &DmE^PiuՀf4[MCS|N"JIL]w71d~h(F`_ l+'Mgp ׌9Kp2LLhBPeML`?5}v0K.Gqs]e+JF-O]H?P6{|d=Gt[7 *Y@*&HV7D7EySRv%$7\\/9h 5$!;Ug.Ȥ}R dJ-.r%uݬk%keCnIn5+b =-|ʤ}QX+? VY(IlrM-&%~aMU H3̪GI\8B@P 3=oC'9YV^@ō;%A@B4Ǜ@> JIYlނ+^3;DS#$U~ 0ΤƄ%T=`?5}vdv?˷=/"-O]H>a a(yki'{|d=G!;߰|M__[7 *Y߾$ dkc|$MEySRv%:BmjO78(4J9n1"%Č$}R YUQ, v+ַvQZs䛵[\Dn4j7U@tJZ@CRwQR\ 2Y OJ.!hT@nݻvC49gS1 m -y3 ,-1Tl\]7,N^GhƣӆRoX-‘Pv䓌z3 &U'C:ER?GZ.35t]XkʝC9k sk:%\qWBq0X__q{!Y:S@dz;8JYqag}Nю9?k{9eSsu eojM]4X+6e+\x[aL+1 nl"lR?!!!!!!E?Nfҙ6"+l /9]}XCpB9LP1[pOʂ9"Cl#-c&MhZXZ &딞fo *GťrCjTe]f޸)m1c3< 9qrt"b۷p8/UYvu9 6jz^clBjr!'ڍz)R?Yg&3o?!Q^QH!eNw&rtV/>! {:?$&cBcDRU9xxȹ *{KH+eh;5mQɉ=he&?Eq0Pr\glVEKj4 A+ GTF29͘(FZ~wSV5^zcotE|:m_Z9"V'M$7(_TD4bOPU8Hcs4p%Gk~FQ..aߜek+=J*?^zʀjvB%'.s7#VTKQ4{{v{g5{mWfcͧx%<ދۼ u%(e) >e`ȳb3PvjB[6$D\*نoK4ivWqN ,zX/]'gҴ<O; hPm~t4RXJس EY#yw_1fK"n̑άJurJ~.ɷdΪl-PsB o=M\~B'SH4a롯^x%yCz/o̦s\J}5w& 1`@Y11CR=aC;c;:vX'*4G]Xݲv5pz\,#?P^+.89!i4gۣPS Ͽxm\}*~EJ*ę;\5PkX*E_UJʷg?ečOLi.Jm32I=۶Ajջf3ـtʕ3-w+ vb*!?I}HINXf}w1+8]=;DBIbыU\pV(0:2uI|l]`;9ْaG,|,|Bޠ$ A T3jC6D"ڨLRh^8,nؕ}6k/R>,9}\.O?Zwh ~TM,6OKg+-[_8p6lւ\sYkyvTvu]&0?mE#{[j]Ǵ*ykgd,;YH(uH 4CN e!ξph,".l&&dW%Ϩ"$A2Nnc@׏l"ARxhTEtg^9Jr?̉`2zA(3e~DxDU,ocXzߟ*Y= 9 ye ?0/ ̳Qew% c=_DacV)wɉK7S_V1H)׸ /r)[(SbI3)] 4;%8b*!2=z__u_ϰa;r#*Z4ӷS޲M-LQV&jrFVFOb?TE]F /F9rKOU>^Fwm<몛 Tzi4:Lz1g$,u.KBuJ;~fNZOB|hǰm8% 8@f&4:8 Bx$}gQ6 Dn㈏]c EGlž5a0R8&iXN( 0$b '~*CP,Bs}̀42G$\k՜tͻ._Wӥ\gT&u8gG}|%dc`yG%f2cCz0\qp;g~/徫OcUzwFD_]C2[0h7ut#ZkkZMGTQ = Є)B<1VRV0v!*U11 [+MzQ4-q}?X*VtB/&+_ ɶ-V9<`9?Ҿm`0`LG@$MfHeL`E&3XT+ Al+.CRS3ʹVOxU+ GoF{i2݂a-k҉8!fFÓt)YW=sf؆LYȅ\_g@} ؎:ݨ2Z–h5 [p3gza UtQYOSSeCNzGnvᵡb G$;?yu݀f4[MS};ZȵM]g" V|\nXuD`) D#Gf@0\JiSYtٷU*k:;MFwr%wDOz%7iYMMJetjo 7d7w4#vg[sgam}HY+7bV*&+Rҟpm?T]U)Z†|ĶƿʼLY'8lFUyeHCq]T$H}9WIFTXXtDvB' v%niBE|b( %BHH{c&h(Fـ A#KUb u9 IIġ+VjLim+q́3(\7S&;uA3j*}vtt0۰>;Ia?lpB@F<5\˕H$YxZEJjY@ 0=TNnBM$yS}, >Eٸ9vOH-rt=+n 7sK|1/'|Eiq 9{L)a՝GdqιCxvW>GVu"v>zARࡿ./:4֧bh,cFA<w$޲N MC$ɶ.,ұU꿺I~UIJMGR& RxnqF,lxcg %vIQk˝BS~B =_:,ؑ_AJ߱)7dQyI5mO))lȑd}E_%Y1T+NӬ}ONWgZ觥)aGM,vݹLw:6D4V%z\l%{WP$+-Y2~')_ +e]"NhA#KVȶr3PFer{wBPݓFAv)YJ9;2_U҃/濨 *x4_4 'l]BБ!8).V #?eU¦ζU,:޿9ٶT;zůy I׭ ~T7I(>kTed]yz\yc”לT ݵ}T-[q`YwˊtK\|gy XES(05rDw%2c܏F,q ;ܹ_rư%ힰD1L#eLoD". }LZ{V~K@?H"~~V΢CAN&7같o0 mұ( ͅ L錚ZlvĻ8k u$Wv7"B-d"[/?P  p13(WTHУ]*oHȡyYS8ٖiQiYV+<.1X3qD'6bo].E\-f[::J&F[5g dϡ|w&.^*}sG?9iú+(w{{F4L3~G2Ehz|6Y zc>HYaZD%Dqi% +`&2!,lkjAX!|uzAزdT/7hI ڕKNxotKR[mEMтF& ?GV:w4!yj nh80ľL9r#ngo \kY%'|,YC`5 \Vڲ&GJnֳ32*,EUȺ*U83\e~6 DK/̪T۲ _'?$R$R~ ȗeOO޼zWWBzI( ͬO#]8e_\6a@MffX|nJYɄA,S.檫ҷo,$IV>  KoNЦdaK#ӃPPOôd{1m֋99,~"մJ:R 0~Jg|pSyG>=J,pwYw9IvXf*U'(/̖h;7Sr: qJ̤$Eܩ虔?'fRv>sM*JBm*eaeٞ+u6z1gXu%ﭻv@dFkkWbHn]yJƻ6bk2W`D}˂2On F"~Jz"dXS|@1rG(L2WiߚҿDjŪ*a|/%Uv[uanۖei6Uq\V][vգkwoYumSSeqPӖ 7T5,tܔO]}!Igj]EZ}4r=Z!Mev)j\˖XuL-LZ=k~B dy):eA8q52A6AѶ϶g[m#F%q \AKs#h@> QsY|n'EckM*\ƾS)hcvy?D8B3d^jkJSY*{CAhXrWn]edWnYbZv}dV]qZvumVye]{`U%MLUCNX4SH詨'rRu=v=%z&m.kFHʈkS~)|=r.Z7b5jF,2j^, A:Ruʂ RGb٫d%&gGj4>bcZRNs?1U ᑴ {qpՅ(t,>{D#aZ%k8ȟ0 )cv˙N.Ȟ2rǣ#>'o5D_5ZWȑUUSصF}C! BÐ䪾r,;"Zݷ,ӳ&ʲ+ӴnF׼ .޲*jb*rnkEMFY>)+ߑy'z6{yV$idʈk7MZ,2wb5֯Ti2i+aDW*/ϲ-+Tc@ᢃI5Q>f` Zu~(9`kpGxe$IՓA&̵ˮZT2.TpkEZI" G;&eV$0d8g>1Tl(mǰh :oN{.IU^~%^Jooan Fo^I2ۥw5LsF0AQbꭵ[іf*pI⧚O&*It~kAq+n.IYZS r^$#ZV]X.IƞfpNZ0V3Eu _HGYѐV.튺LK)(iZZ+F*I HZӱ Y0 5~Bj8ߘGs+{SR"Bi-$`VS % 9Ki΄P&pU~/nNrmmW|GjGn \f`.&&PU;1plFQR2A3A7B I Lth o(Cc(YDFdcnwre~HW@Pfu /=҇'[tJ j]}QᩚjYvkᲖxyxdYR]mXFhrZU`е !Tx+3Ĭhj6q|C{:3 #Z!!77M )Roy %Dz$TleRJ+JvJt'K:Q̦It30d!W$M@Z؞NŠ/`a0MP!6c b,ɲCnL3A68ydxm, V=i+B5[Z^kw\kJf%.~۹ѩ^WK-h'{tnP+ٷ]Y1܎-W.#=9{nR}:e^z SHi]=U%>Y(a1 lQI R-&C RJ/YOUKQGA3- 3u0aJ[ʜdiỊ3 YHpcjwW[<1%o% 7A b_*%7$01&ʪJi 0ph-;Glt' ۿ%Y/@˔{m^%zY(Qq\4QFl֕&:KZw:58CZ>N vܠWv+7;Ū]Gzsn庭OL]RK}=[%/Z[/UjR_$#n1=) ZZL%PbV'TB4-T3L"BZQ| Y)*¦ n?äfQ $8V䱵OHxiH\ @"O!# BlL0*̛*FCnL3 N? Ch+rW^JAj[EI_k΍N^9kD;:B](݊qjtgi!ޜ[mnr-" WMJ.uy DkJJ)uwT]d=1ۥO`2HJi2 (e3VRm 'YI[Іp4!Zu@r%G>߆Ew,xN{,wbƸaB6!0¬dpDrX2D"R$zF;z>C1B1GՅOc^d2vk?l۬w3:|=~/=^\9+unR(ZlH[% Ty::eVȇ2 R-,\p1jiG_V1Ⱦ_pi30;O.<,d\|E]VG c >v2HռM!75J u^5BK-LyF]M>4am]EwmΚeh:4of&L%C Xt~?`TޣF%5] i2g!! -AG}HHc7rIǹpܺmޏAU?==4v~mzDS,U i垑cXTNObɵǹU<)mI`餓ceք %ΣSug?~<|q Őfs0ӰBbWXH{?'^{!Ń ieΚ"c!T̔ W22} uӍ ' V#خ@=Ga:A8hRR`JbMg,ԱEF FnV4+$ݭg܈8ַ-6NK'ҕXXr䝪) L]SMKkKRyI&?o{xYa,ˮtxe_GⳄUS:ӌBz )+1n(fs+Q 3DCEQ_q&X%|Ifa3m7e| 3- ~Ɇ0Q b#%vG*S]$4+u*1@ E* ٬9a:Jrq&fR]l'7Y.DH%MLop]PR$=rKxR&iBNቾ)[-P~ȰnAetUyrsߐm">Jy*}hȉ ڛ˪ [y$I/j@$-=#ޅ雦7E1keT,rzYun~;`ͤZV%a{YV֭sN*TT5.`GezUeڨ:?Rn|oq,+]HckAekjs !r22Y0mجaH3@ ;]|T::?FP'18ln εXNc0ݤ]riLL3u[o{ HapG_ciG<&{FJ±;zTXGZ)6ԈNAx獝e˵:ۜK7 lglNKޯ5fPv?At.B.MJtoBlDPKw +s1P4VL+q ҰX9 7 !=o˸&hg7k)I!Kk"K_A%?3rL^rQvύzQ!PB'B=,Wc~%_QԪj˥=7*(W{{ 31̗8t!sC _HnWEok̅+, you should probably be using // html/i18n_template.html instead of this 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