def __init__(self, application, port, content_db_query, *args, middlewares=None, **kwargs): '''Initialize the service and create webserver on port. :content_db_query: is a function satisfying the signature def content_db_query(app_id: str, query: dict, callback: GAsyncReadyCallback) which can be used by a route to query a content database of some sort for an app_id. ''' super().__init__(*args, **kwargs) # Create the server now and start listening. # # We want to listen right away as we'll probably be started by # socket activation self._cache = EosCompanionAppService.ManagedCache() self._monitors = configure_drop_cache_on_changes(self._cache) self._server = create_companion_app_webserver(application, self._cache, content_db_query, middlewares=middlewares) EosCompanionAppService.soup_server_listen_on_sd_fd_or_port(self._server, port, 0)
def _render_license_css_content(content_bytes, version, query, source_path): '''Render the CSS license content by rewriting all the URIs.''' unrendered_css_string = EosCompanionAppService.bytes_to_string( content_bytes) return EosCompanionAppService.string_to_bytes( _RE_CSS_URL_CAPTURE.sub( relative_css_url_rewriter(version, query, source_path), unrendered_css_string))
def not_found_response(msg, path): '''Respond with an error message and 404.''' msg.set_status(Soup.Status.NOT_FOUND) error = serialize_error_as_json_object( EosCompanionAppService.error_quark(), EosCompanionAppService.Error.INVALID_REQUEST, detail={'invalid_path': path}) EosCompanionAppService.set_soup_message_response( msg, 'application/json', json.dumps({ 'status': 'error', 'error': error }))
def _on_rendered_wrapper(error, rendered_page): '''Called when rendering the wrapper is complete.''' if error is not None: callback(error, None) return try: response_bytes = EosCompanionAppService.string_to_bytes( pipeline( rendered_page, lambda content: _RE_EKN_URL_CAPTURE.sub( ekn_url_rewriter(version, query), content ), lambda content: _RE_RESOURCE_URL_CAPTURE.sub( resource_url_rewriter(version, query), content ), lambda content: _RE_LICENSE_URL_CAPTURE.sub( license_url_rewriter(version, query), content ) ) ) except GLib.Error as render_bytes_error: callback(render_bytes_error, None) return callback(None, response_bytes)
def _internal(_load_app_info_callback): def _on_got_application_info(_, result): '''Marshal the error and result into a tuple.''' try: info = application_listing_from_app_info( EosCompanionAppService.finish_load_application_info( result)) except GLib.Error as error: _load_app_info_callback(error, None) return _load_app_info_callback(None, info) application_id = desktop_id_to_app_id(desktop_id) EosCompanionAppService.load_application_info( application_id, cache, cancellable, _on_got_application_info)
def list_all_applications(cache, cancellable, callback): '''Convenience function to pass list of ApplicationListing to callback.''' def _callback(_, result): '''Callback function that gets called when we are done.''' try: infos = EosCompanionAppService.finish_list_application_infos( result) except GLib.Error as error: callback(error, None) return callback(None, [ application_listing_from_app_info(app_info) for app_info in infos ]) EosCompanionAppService.list_application_infos(cache, cancellable, _callback)
def _callback(_, result): '''Marshal the GAsyncReady callback into an (error, data) callback.''' try: bytes_data = EosCompanionAppService.finish_load_all_in_stream_to_bytes( result) except GLib.Error as error: callback(error, None) return callback(None, bytes_data)
def _on_got_application_info(_, result): '''Marshal the error and result into a tuple.''' try: info = application_listing_from_app_info( EosCompanionAppService.finish_load_application_info( result)) except GLib.Error as error: _load_app_info_callback(error, None) return _load_app_info_callback(None, info)
def _read_stream_callback(_, result): '''Callback once we have finished loading the stream to bytes.''' try: content_bytes = EosCompanionAppService.finish_load_all_in_stream_to_bytes( result) except GLib.Error as error: callback(error, None) return adjuster.render_async(content_type, content_bytes, version, query, cache, cancellable, _content_adjusted_callback)
def _callback(_, result): '''Callback function that gets called when we are done.''' try: infos = EosCompanionAppService.finish_list_application_infos( result) except GLib.Error as error: callback(error, None) return callback(None, [ application_listing_from_app_info(app_info) for app_info in infos ])
def load_record_from_shards_async(shards, content_id, attr, callback): '''Load bytes from stream for app and content_id. :attr: must be one of 'data' or 'metadata'. Once loading is complete, callback will be invoked with a GAsyncResult, use EosCompanionAppService.finish_load_all_in_stream_to_bytes to get the result or handle the corresponding error. Returns LOAD_FROM_ENGINE_SUCCESS if a stream could be loaded, LOAD_FROM_ENGINE_NO_SUCH_CONTENT if the content wasn't found. ''' def _callback(_, result): '''Marshal the GAsyncReady callback into an (error, data) callback.''' try: bytes_data = EosCompanionAppService.finish_load_all_in_stream_to_bytes( result) except GLib.Error as error: callback(error, None) return callback(None, bytes_data) status, blob = load_record_blob_from_shards(shards, content_id, attr) if status == LOAD_FROM_ENGINE_NO_SUCH_CONTENT: GLib.idle_add(lambda: callback( GLib.Error( 'EKN ID {} not found in shards'.format(content_id), GLib.quark_to_string(EosCompanionAppService.error_quark()), EosCompanionAppService.Error.INVALID_CONTENT_ID), None)) return EosCompanionAppService.load_all_in_stream_to_bytes( blob.get_stream(), chunk_size=BYTE_CHUNK_SIZE, cancellable=None, callback=_callback)
def _on_got_application_info(_, result): '''Callback function that gets called when we get the app info.''' try: app_info = EosCompanionAppService.finish_load_application_info(result) except GLib.Error as error: callback(error, None) return content_db_conn.query(application_listing=application_listing_from_app_info(app_info), query={ 'tags-match-all': ['EknSetObject'] }, cancellable=cancellable, callback=_on_queried_sets)
def conditionally_wrap_stream(stream, content_size, content_type, version, query, adjuster, cache, cancellable, callback): '''Inspect content_type to adjust stream content.''' def _content_adjusted_callback(error, adjusted): '''Callback once we have finished adjusting the content.''' if error is not None: callback(error, None) return memory_stream = Gio.MemoryInputStream.new_from_bytes(adjusted) callback(None, (memory_stream, adjusted.get_size())) def _read_stream_callback(_, result): '''Callback once we have finished loading the stream to bytes.''' try: content_bytes = EosCompanionAppService.finish_load_all_in_stream_to_bytes( result) except GLib.Error as error: callback(error, None) return adjuster.render_async(content_type, content_bytes, version, query, cache, cancellable, _content_adjusted_callback) if adjuster.needs_adjustment(content_type): EosCompanionAppService.load_all_in_stream_to_bytes( stream, chunk_size=BYTE_CHUNK_SIZE, cancellable=cancellable, callback=_read_stream_callback) return # We call callback here on idle so as to ensure that both invocations # are asynchronous (mixing asynchronous with synchronous disguised as # asynchronous is a bad idea) GLib.idle_add(lambda: callback(None, (stream, content_size)))
def middleware(server, msg, path, query, *args): '''Middleware to check the query parameters.''' rectified_query = query or {} if not rectified_query.get(param, None): return json_response( msg, { 'status': 'error', 'error': serialize_error_as_json_object( EosCompanionAppService.error_quark(), EosCompanionAppService.Error.INVALID_REQUEST, detail={'missing_querystring_param': param}) }) return handler(server, msg, path, rectified_query, *args)
def _on_finished_loading_shards(shard_init_results): '''Callback for when shards have finished initializing. Check for any errors and invoke the callback accordingly, otherwise, invoke the callback with the resolved shards and metadata now. ''' try: callback(None, list(_iterate_init_shard_results(shard_init_results))) except GLib.Error as error: callback(GLib.Error(error.message, # pylint: disable=no-member EosCompanionAppService.error_quark(), EosCompanionAppService.Error.FAILED), None) return
def translate_error(error, error_mappings=None): '''Translate any error type into an EosCompanionAppError error.''' if error.domain == GLib.quark_to_string( EosCompanionAppService.error_quark()): return (EosCompanionAppService.error_quark(), EosCompanionAppService.Error(error.code)) for src_domain, src_code, target_code in generate_error_mappings( error_mappings): if error.matches(src_domain, src_code): return (EosCompanionAppService.error_quark(), EosCompanionAppService.Error(target_code)) return (EosCompanionAppService.error_quark(), EosCompanionAppService.Error.FAILED)
def _on_received_application_info(_, result): '''Called when we receive requested application info.''' try: listing = application_listing_from_app_info( EosCompanionAppService.finish_load_application_info(result)) except Exception as error: done_callback(error, None) return done_callback(None, [{ 'tags': _GLOBAL_SET_INDICATOR_TAG, 'title': listing.display_name, 'contentType': 'application/x-ekncontent-set', 'thumbnail': format_app_icon_uri(version, listing.icon, device_uuid), 'id': '', 'global': True }])
def configure_drop_cache_on_changes(cache): '''Configure :cache: to be dropped when the Flatpak installation state changes. This creates a Gio.FileMonitor over each of the configured Flatpak installations on the system and drops all caches when they change. Note that we cannot use the Flatpak API here directly as we are running from within Flatpak. We are relying on an internal implementation detail, namely that Flatpak itself will update ".changed" in the installation directory on state changes. ''' def _on_installation_changed(*args): '''Callback for when something changes.''' del args cache.clear() return list( yield_monitors_over_changed_file_in_paths( EosCompanionAppService.flatpak_install_dirs(), _on_installation_changed ) )
def main(args=None): '''Entry point function. Since we're often running from within flatpak, make sure to override XDG_DATA_DIRS to include the flatpak exports too, since they don't get included by default. We use GLib.setenv here, since os.environ is only visible to Python code, but setting a variable in os.environ does not actually update the 'environ' global variable on the C side. ''' flatpak_export_share_dirs = [ os.path.join(d, 'exports', 'share') for d in EosCompanionAppService.flatpak_install_dirs() ] GLib.setenv( 'XDG_DATA_DIRS', os.pathsep.join([GLib.getenv('XDG_DATA_DIRS') or ''] + flatpak_export_share_dirs), True) logging.basicConfig( format='CompanionAppService %(levelname)s: %(message)s', level=get_log_level()) CompanionAppApplication().run(args or sys.argv)
def _dbus_error_to_companion_app_error(error): '''Translate the GDBusError from EknServices to a Companion App error.''' code = _EKS_ERROR_TO_COMPANION_APP_ERROR[Gio.DBusError.get_remote_error(error)] return GLib.Error(error.message, # pylint: disable=no-member EosCompanionAppService.error_quark(), code)
def render_mobile_wrapper(renderer, app_id, rendered_content, metadata, content_db_conn, shards, version, query, cache, cancellable, callback): '''Render the page wrapper and initialize crosslinks. This is the final rendering step that EKN content needs to undergo before it becomes usable, at least before it leaves the server. This step initializes both a dictionary of metadata about the content and also a map of outgoing links to internal links. Then we inject some javascript which at browser-render time, rewrites the page to resolve all those links. ''' def _on_queried_sets(error, result): '''Called when we finish querying set objects.''' if error is not None: callback(error, None) return _, set_objects = result content_metadata = { 'title': metadata.get('title', ''), 'published': metadata.get('published', ''), 'authors': metadata.get('authors', []), 'license': metadata.get('license', ''), 'source': metadata.get('source', ''), 'source_name': metadata.get('sourceName', ''), 'originalURI': metadata.get('originalURI', ''), 'sets': [ { 'child_tags': set_object['child_tags'], 'id': set_object['id'], 'title': set_object['title'], 'tags': set_object['tags'] } for set_object in set_objects if any([ tag in set_object['child_tags'] for tag in metadata.get('tags', []) if not tag.startswith('Ekn') ]) ] } # Now that we have everything, read the template and render it variables = GLib.Variant('a{sv}', { 'css-files': GLib.Variant('as', [ 'clipboard.css', 'share-actions.css' ]), 'custom-css-files': GLib.Variant('as', []), 'javascript-files': GLib.Variant('as', [ 'jquery-min.js', 'collapse-infotable.js', 'crosslink.js' ]), 'content': GLib.Variant('s', rendered_content), 'crosslink-data': GLib.Variant('s', json.dumps(link_resolution_table)), 'content-metadata': GLib.Variant('s', json.dumps(content_metadata)), 'title': GLib.Variant( 's', metadata.get('title', 'Content from {app_id}'.format(app_id=app_id)) ) }) try: template_file = Gio.File.new_for_uri(_MOBILE_WRAPPER_TEMPLATE_URI) rendered_page = renderer.render_mustache_document_from_file(template_file, variables) except GLib.Error as page_render_error: callback(page_render_error, None) return callback(None, rendered_page) def _on_got_application_info(_, result): '''Callback function that gets called when we get the app info.''' try: app_info = EosCompanionAppService.finish_load_application_info(result) except GLib.Error as error: callback(error, None) return content_db_conn.query(application_listing=application_listing_from_app_info(app_info), query={ 'tags-match-all': ['EknSetObject'] }, cancellable=cancellable, callback=_on_queried_sets) link_tables = list(link_tables_from_shards(shards)) link_resolution_table = [ maybe_ekn_id_to_server_uri( resolve_outgoing_link_to_internal_link(link_tables, l), version, query ) for l in metadata.get('outgoingLinks', []) ] EosCompanionAppService.load_application_info(app_id, cache=cache, cancellable=cancellable, callback=_on_got_application_info)
def _html_content_adjuster(content_bytes, version, query, metadata, content_db_conn, shards, cache, cancellable, callback): '''Adjust HTML content by rewriting all the embedded URLs. There will be images, video and links in the document, parse it and rewrite it so that the links all go to somewhere that can be resolved by the server. Right now the way that this is done is a total hack (regex), in future we might want to depend on beautifulsoup and use that instead. ''' def _on_rendered_wrapper(error, rendered_page): '''Called when rendering the wrapper is complete.''' if error is not None: callback(error, None) return try: response_bytes = EosCompanionAppService.string_to_bytes( pipeline( rendered_page, lambda content: _RE_EKN_URL_CAPTURE.sub( ekn_url_rewriter(version, query), content ), lambda content: _RE_RESOURCE_URL_CAPTURE.sub( resource_url_rewriter(version, query), content ), lambda content: _RE_LICENSE_URL_CAPTURE.sub( license_url_rewriter(version, query), content ) ) ) except GLib.Error as render_bytes_error: callback(render_bytes_error, None) return callback(None, response_bytes) unrendered_html_string = EosCompanionAppService.bytes_to_string(content_bytes) # We need the content_db_conn, shards and metadata # in order to render our mobile wrapper. if (metadata is not None and content_db_conn is not None and shards is not None): if not metadata.get('isServerTemplated', False): rendered_content = renderer.render_legacy_content( unrendered_html_string, metadata.get('source', ''), metadata.get('sourceName', ''), metadata.get('originalURI', ''), metadata.get('license', ''), metadata.get('title', ''), show_title=True, use_scroll_manager=False ) else: rendered_content = unrendered_html_string render_mobile_wrapper(renderer, query['applicationId'], rendered_content, metadata, content_db_conn, shards, version, query, cache, cancellable, _on_rendered_wrapper) else: # Put this on an idle timer, mixing async and sync code # is a bad idea GLib.idle_add(lambda: _on_rendered_wrapper(None, unrendered_html_string))
def custom_response(msg, content_type, content_bytes): '''Respond with :content_type: using :content_bytes:.''' msg.set_status(Soup.Status.OK) EosCompanionAppService.set_soup_message_response_bytes( msg, content_type, content_bytes)
def jpeg_response(msg, image_bytes): '''Respond with image/jpeg bytes.''' msg.set_status(Soup.Status.OK) EosCompanionAppService.set_soup_message_response_bytes( msg, 'image/jpeg', image_bytes)
def html_response(msg, html): '''Respond with an HTML body.''' msg.set_status(Soup.Status.OK) EosCompanionAppService.set_soup_message_response(msg, 'text/html', html)
def json_response(msg, obj): '''Respond with a JSON object''' msg.set_status(Soup.Status.OK) EosCompanionAppService.set_soup_message_response(msg, 'application/json', json.dumps(obj))
def ascertain_application_sets_from_models(models, version, device_uuid, application_id, cache, cancellable, done_callback): '''Pass application sets or an entry for the global set to callback.''' def _on_received_application_info(_, result): '''Called when we receive requested application info.''' try: listing = application_listing_from_app_info( EosCompanionAppService.finish_load_application_info(result)) except Exception as error: done_callback(error, None) return done_callback(None, [{ 'tags': _GLOBAL_SET_INDICATOR_TAG, 'title': listing.display_name, 'contentType': 'application/x-ekncontent-set', 'thumbnail': format_app_icon_uri(version, listing.icon, device_uuid), 'id': '', 'global': True }]) try: any_featured = any([model.get('featured', True) for model in models]) application_sets_response = [ { 'tags': model['child_tags'], 'title': model['title'], 'contentType': 'application/x-ekncontent-set', 'thumbnail': optional_format_thumbnail_uri(version, application_id, model, device_uuid), 'id': urllib.parse.urlparse(model['id']).path[1:], 'global': False } for model in models # Filter out sets explicitly not marked featured, except # if nothing was featured - in that case include everything if model.get('featured', True) or not any_featured ] if application_sets_response: GLib.idle_add(done_callback, None, application_sets_response) return EosCompanionAppService.load_application_info( application_id, cache, cancellable=cancellable, callback=_on_received_application_info) except Exception as error: GLib.idle_add(done_callback, error, None)