diff options
Diffstat (limited to 'webhelper')
-rw-r--r-- | webhelper/Makefile.am.inc | 12 | ||||
-rw-r--r-- | webhelper/lib/wh2private.c | 36 | ||||
-rw-r--r-- | webhelper/lib/wh2private.h | 20 | ||||
-rw-r--r-- | webhelper/webextensions/wh2extension.c | 524 | ||||
-rw-r--r-- | webhelper/webextensions/wh2jscutil.c | 65 | ||||
-rw-r--r-- | webhelper/webextensions/wh2jscutil.h | 25 | ||||
-rw-r--r-- | webhelper/webhelper2.js | 500 | ||||
-rw-r--r-- | webhelper/webhelper_private/config.js.in | 1 |
8 files changed, 1171 insertions, 12 deletions
diff --git a/webhelper/Makefile.am.inc b/webhelper/Makefile.am.inc deleted file mode 100644 index 3531000..0000000 --- a/webhelper/Makefile.am.inc +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright 2013 Endless Mobile, Inc. - -# # # INSTALL RULES # # # - -webhelper_sources = webhelper/webhelper.js - -webhelperdir = $(gjsmodulesdir) -dist_webhelper_DATA = \ - $(webhelper_sources) \ - $(NULL) - -EOS_JS_COVERAGE_FILES = $(webhelper_sources) diff --git a/webhelper/lib/wh2private.c b/webhelper/lib/wh2private.c new file mode 100644 index 0000000..04a7710 --- /dev/null +++ b/webhelper/lib/wh2private.c @@ -0,0 +1,36 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/* Copyright 2015 Endless Mobile, Inc. */ + +#include <glib.h> +#include <webkit2/webkit2.h> + +#include "wh2private.h" + +/** + * wh2_private_register_global_uri_scheme: + * @scheme: the network scheme to register + * @callback: a #WebKitURISchemeRequestCallback. + * @user_data: (closure): user data for the @callback + * @notify: destroy notify function for the @callback + * + * Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=729611 + * + * Registers a URI scheme handler with the default WebContext. Does not pass the + * GDestroyNotifyFunc, which GJS uses to shim a destructor for @callback, along + * to the the web context. + * + * The default web context is a global object which does not get destroyed + * until a atexit handler after the javascript runtime has been torn down. + * Calling into the GJS function destructor at that point would be a + * mistake. + */ +void +wh2_register_uri_scheme (const gchar *scheme, + WebKitURISchemeRequestCallback callback, + gpointer user_data, + GDestroyNotify notify) +{ + WebKitWebContext *context = webkit_web_context_get_default (); + webkit_web_context_register_uri_scheme (context, scheme, callback, NULL, NULL); +} diff --git a/webhelper/lib/wh2private.h b/webhelper/lib/wh2private.h new file mode 100644 index 0000000..a20fe87 --- /dev/null +++ b/webhelper/lib/wh2private.h @@ -0,0 +1,20 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/* Copyright 2015 Endless Mobile, Inc. */ + +#ifndef WH2_PRIVATE_H +#define WH2_PRIVATE_H + +#include <glib.h> +#include <webkit2/webkit2.h> + +G_BEGIN_DECLS + +void wh2_register_uri_scheme (const gchar *scheme, + WebKitURISchemeRequestCallback callback, + gpointer user_data, + GDestroyNotify notify); + +G_END_DECLS + +#endif /* WH2_PRIVATE_H */ diff --git a/webhelper/webextensions/wh2extension.c b/webhelper/webextensions/wh2extension.c new file mode 100644 index 0000000..5b4ad06 --- /dev/null +++ b/webhelper/webextensions/wh2extension.c @@ -0,0 +1,524 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/* Copyright 2015 Endless Mobile, Inc. */ + +#include <math.h> +#include <string.h> + +#include <glib.h> +#include <JavaScriptCore/JavaScript.h> +#include <webkit2/webkit-web-extension.h> +#include <webkitdom/webkitdom.h> + +#include "wh2jscutil.h" + +#define PRIVATE_NAME "_webhelper_private" +#define WH2_DBUS_INTERFACE_NAME "com.endlessm.WebHelper2.Translation" +#define MAIN_PROGRAM_OBJECT_PATH "/com/endlessm/gettext" +#define MAIN_PROGRAM_INTERFACE_NAME "com.endlessm.WebHelper2.Gettext" + +/* Declaration of externally visible symbol */ +void webkit_web_extension_initialize_with_user_data (WebKitWebExtension *, const GVariant *); + +typedef struct { + WebKitWebExtension *extension; /* unowned */ + GDBusConnection *connection; /* unowned */ + GDBusNodeInfo *node; /* owned */ + GDBusInterfaceInfo *interface; /* owned by node */ + GSList *bus_ids; /* GSList<guint>; owned */ + GArray *unregistered_pages; /* GArray<guint64>; owned */ + gchar *main_program_name; /* owned; well-known-name of main program */ +} Context; + +typedef struct { + Context *ctxt; /* unowned */ + guint64 page_id; +} PageContext; + +static const gchar introspection_xml[] = + "<node>" + "<interface name='" WH2_DBUS_INTERFACE_NAME "'>" + "<method name='Translate'/>" + "</interface>" + "</node>"; + +static void +context_free (Context *ctxt) +{ + g_clear_pointer (&ctxt->node, g_dbus_node_info_unref); + g_clear_pointer (&ctxt->bus_ids, g_slist_free); + if (ctxt->unregistered_pages != NULL) + g_array_free (ctxt->unregistered_pages, TRUE); + ctxt->unregistered_pages = NULL; + g_clear_pointer (&ctxt->main_program_name, g_free); + g_free (ctxt); +} + +static gchar * +translation_function (const gchar *message, + Context *ctxt) +{ + GError *error = NULL; + GVariant *result = + g_dbus_connection_call_sync (ctxt->connection, ctxt->main_program_name, + MAIN_PROGRAM_OBJECT_PATH, + MAIN_PROGRAM_INTERFACE_NAME, "Gettext", + g_variant_new ("(s)", message), + (GVariantType *) "(s)", + G_DBUS_CALL_FLAGS_NO_AUTO_START, + -1 /* timeout */, NULL /* cancellable */, + &error); + if (result == NULL) + { + g_warning ("No return value from gettext: %s", error->message); + g_clear_error (&error); + return g_strdup (message); + } + + gchar *retval; + g_variant_get (result, "(s)", &retval); + g_variant_unref (result); + return retval; +} + +static gchar * +ngettext_translation_function (const gchar *singular, + const gchar *plural, + gulong number, + Context *ctxt) +{ + GError *error = NULL; + GVariant *result = + g_dbus_connection_call_sync (ctxt->connection, ctxt->main_program_name, + MAIN_PROGRAM_OBJECT_PATH, + MAIN_PROGRAM_INTERFACE_NAME, "NGettext", + g_variant_new ("(sst)", singular, plural, + number), + (GVariantType *) "(s)", + G_DBUS_CALL_FLAGS_NO_AUTO_START, + -1 /* timeout */, NULL /* cancellable */, + &error); + if (result == NULL) + { + g_warning ("No return value from ngettext: %s", error->message); + g_clear_error (&error); + return g_strdup (number == 1 ? singular : plural); + } + + gchar *retval; + g_variant_get (result, "(s)", &retval); + g_variant_unref (result); + return retval; +} + +static JSValueRef +gettext_shim (JSContextRef js, + JSObjectRef function, + JSObjectRef this_object, + size_t n_args, + const JSValueRef args[], + JSValueRef *exception) +{ + if (n_args != 1) + { + gchar *errmsg = g_strdup_printf ("Expected one argument to gettext()," + "but got %d.", n_args); + *exception = throw_exception (js, errmsg); + g_free (errmsg); + return NULL; + } + if (!JSValueIsString (js, args[0])) + { + *exception = throw_exception (js, + "Expected a string argument to gettext()."); + return NULL; + } + + JSObjectRef window = JSContextGetGlobalObject (js); + JSStringRef private_name = JSStringCreateWithUTF8CString (PRIVATE_NAME); + JSValueRef private_data = JSObjectGetProperty (js, window, private_name, + exception); + if (JSValueIsUndefined (js, private_data)) + return NULL; /* propagate exception */ + Context *ctxt = (Context *) JSObjectGetPrivate ((JSObjectRef) private_data); + + JSStringRef message_ref = JSValueToStringCopy (js, args[0], exception); + if (message_ref == NULL) + return NULL; /* propagate exception */ + gchar *message = string_ref_to_string (message_ref); + JSStringRelease (message_ref); + + gchar *translation = translation_function (message, ctxt); + g_free (message); + + JSValueRef retval = string_to_value_ref (js, translation); + g_free (translation); + return retval; +} + +static JSValueRef +ngettext_shim (JSContextRef js, + JSObjectRef function, + JSObjectRef this_object, + size_t n_args, + const JSValueRef args[], + JSValueRef *exception) +{ + if (n_args != 3) + { + gchar *errmsg = g_strdup_printf ("Expected three arguments to ngettext()," + "but got %d.", n_args); + *exception = throw_exception (js, errmsg); + g_free (errmsg); + return NULL; + } + if (!JSValueIsString (js, args[0])) + { + *exception = throw_exception (js, "The first argument to ngettext() " + "must be a string."); + return NULL; + } + if (!JSValueIsString (js, args[1])) + { + *exception = throw_exception (js, "The second argument to ngettext() " + "must be a string."); + return NULL; + } + if (!JSValueIsNumber (js, args[2])) + { + *exception = throw_exception (js, "The third argument to ngettext() " + "must be a number."); + return NULL; + } + + JSObjectRef window = JSContextGetGlobalObject (js); + JSStringRef private_name = JSStringCreateWithUTF8CString (PRIVATE_NAME); + JSValueRef private_data = JSObjectGetProperty (js, window, private_name, + exception); + if (JSValueIsUndefined (js, private_data)) + return NULL; /* propagate exception */ + Context *ctxt = (Context *) JSObjectGetPrivate ((JSObjectRef) private_data); + + JSStringRef singular_ref = JSValueToStringCopy (js, args[0], exception); + if (singular_ref == NULL) + return NULL; /* propagate exception */ + gchar *singular_msg = string_ref_to_string (singular_ref); + JSStringRelease (singular_ref); + + JSStringRef plural_ref = JSValueToStringCopy (js, args[1], exception); + if (plural_ref == NULL) + { + g_free (singular_msg); + return NULL; /* propagate exception */ + } + gchar *plural_msg = string_ref_to_string (plural_ref); + JSStringRelease (plural_ref); + + double number = JSValueToNumber (js, args[2], exception); + if (isnan (number)) + { + g_free (singular_msg); + g_free (plural_msg); + return NULL; /* propagate exception */ + } + + gchar *translation = ngettext_translation_function (singular_msg, plural_msg, + (gulong) number, ctxt); + g_free (singular_msg); + g_free (plural_msg); + + JSValueRef retval = string_to_value_ref (js, translation); + g_free (translation); + return retval; +} + +static void +translate_html (WebKitDOMDocument *dom, + Context *ctxt) +{ + WebKitDOMNodeList *translatable; + GError *error = NULL; + + translatable = webkit_dom_document_get_elements_by_name (dom, "translatable"); + + gulong index, length = webkit_dom_node_list_get_length (translatable); + for (index = 0; index < length; index++) + { + WebKitDOMNode *element = webkit_dom_node_list_item (translatable, index); + + /* Translate the text */ + if (WEBKIT_DOM_IS_HTML_ELEMENT (element)) + { + WebKitDOMHTMLElement *el_html = WEBKIT_DOM_HTML_ELEMENT (element); + gchar *inner_html = webkit_dom_html_element_get_inner_html (el_html); + gchar *translated_html = translation_function (inner_html, ctxt); + webkit_dom_html_element_set_inner_html (el_html, translated_html, + &error); + if (error != NULL) + { + g_warning ("There was a problem translating '%s' to '%s': %s", + inner_html, translated_html, error->message); + g_clear_error (&error); + } + + g_free (translated_html); + g_free (inner_html); + } + else + { + gchar *text = webkit_dom_node_get_text_content (element); + gchar *translated_text = translation_function (text, ctxt); + webkit_dom_node_set_text_content (element, translated_text, &error); + if (error != NULL) + { + g_warning ("There was a problem translating '%s' to '%s': %s", + text, translated_text, error->message); + g_clear_error (&error); + } + + g_free (translated_text); + g_free (text); + } + } + + g_object_unref (translatable); +} + +static void +on_wh2_method_call (GDBusConnection *connection, + const gchar *sender, + const gchar *object_path, + const gchar *interface_name, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation, + PageContext *pctxt) +{ + if (strcmp (method_name, "Translate") != 0) + { + g_dbus_method_invocation_return_error (invocation, G_DBUS_ERROR, + G_DBUS_ERROR_UNKNOWN_METHOD, + "Unknown method %s invoked on interface %s", + method_name, interface_name); + return; + } + + WebKitWebPage *page = webkit_web_extension_get_page (pctxt->ctxt->extension, + pctxt->page_id); + if (page == NULL) + return; + /* The page may have been destroyed, but WebKit doesn't let us find out. */ + + WebKitDOMDocument *document = webkit_web_page_get_dom_document (page); + if (document == NULL) + { + g_dbus_method_invocation_return_error_literal (invocation, G_IO_ERROR, + G_IO_ERROR_NOT_INITIALIZED, + "The web page has not loaded a document yet"); + return; + } + + translate_html (document, pctxt->ctxt); + + g_dbus_method_invocation_return_value (invocation, NULL); +} + +static GDBusInterfaceVTable dbus_impl_vtable = { + (GDBusInterfaceMethodCallFunc) on_wh2_method_call, + NULL, /* get property */ + NULL /* set property */ +}; + +static void +register_object (guint64 page_id, + Context *ctxt) +{ + GError *error = NULL; + + g_assert (ctxt->connection != NULL); + + gchar *object_path = g_strdup_printf("/com/endlessm/webview/%" + G_GUINT64_FORMAT, page_id); + + /* This struct is owned by the registered DBus object */ + PageContext *pctxt = g_new0 (PageContext, 1); + pctxt->ctxt = ctxt; + pctxt->page_id = page_id; + + guint bus_id = + g_dbus_connection_register_object (ctxt->connection, object_path, + ctxt->interface, &dbus_impl_vtable, + pctxt, (GDestroyNotify) g_free, &error); + g_free (object_path); + if (bus_id == 0) + { + g_critical ("Failed to export webview object on bus: %s", error->message); + g_clear_error (&error); + goto fail; + } + + ctxt->bus_ids = g_slist_prepend (ctxt->bus_ids, GUINT_TO_POINTER (bus_id)); + return; + +fail: + g_free (pctxt); +} + +static void +on_page_created (WebKitWebExtension *extension, + WebKitWebPage *page, + Context *ctxt) +{ + /* The ID is known to the main process and the web process. So we can address + a specific web page over DBus. */ + guint64 id = webkit_web_page_get_id (page); + + if (ctxt->connection == NULL) + { + /* The connection is not ready yet. Save the page ID in a list of pages + for which we need to register objects later. */ + g_array_append_val (ctxt->unregistered_pages, id); + return; + } + + register_object (id, ctxt); +} + +/* window-object-cleared is the best time to define properties on the page's +window object, according to the documentation. */ +static void +on_window_object_cleared (WebKitScriptWorld *script_world, + WebKitWebPage *page, + WebKitFrame *frame, + Context *ctxt) +{ + JSGlobalContextRef js = + webkit_frame_get_javascript_context_for_script_world (frame, script_world); + JSObjectRef window = JSContextGetGlobalObject (js); + + /* First we need to create a custom class for a private data object to store + our context in, because you can't pass callback data to JavaScriptCore + callbacks. You also can't set private data on a Javascript object if it's not + of a custom class, because the built-in classes don't allocate space for a + private pointer. */ + JSClassDefinition class_def = { + .className = "PrivateContextObject" + }; + JSClassRef klass = JSClassCreate (&class_def); + JSObjectRef private_data = JSObjectMake (js, klass, ctxt); + JSClassRelease (klass); + + if (!set_object_property (js, window, PRIVATE_NAME, (JSValueRef) private_data, + kJSPropertyAttributeReadOnly | kJSPropertyAttributeDontEnum | kJSPropertyAttributeDontDelete)) + return; + + JSObjectRef gettext_func = + JSObjectMakeFunctionWithCallback (js, NULL, gettext_shim); + if (!set_object_property (js, window, "gettext", (JSValueRef) gettext_func, + kJSPropertyAttributeNone)) + return; + + JSObjectRef ngettext_func = + JSObjectMakeFunctionWithCallback (js, NULL, ngettext_shim); + if (!set_object_property (js, window, "ngettext", (JSValueRef) ngettext_func, + kJSPropertyAttributeNone)) + return; +} + +static void +on_bus_acquired (GDBusConnection *connection, + const gchar *name, + Context *ctxt) +{ + GError *error = NULL; + + ctxt->connection = connection; + + /* Get a notification when Javascript is ready */ + WebKitScriptWorld *script_world = webkit_script_world_get_default (); + g_signal_connect (script_world, "window-object-cleared", + G_CALLBACK (on_window_object_cleared), ctxt); + + /* Export our interface on the bus */ + ctxt->node = g_dbus_node_info_new_for_xml (introspection_xml, &error); + if (ctxt->node == NULL) + goto fail; + ctxt->interface = g_dbus_node_info_lookup_interface (ctxt->node, + WH2_DBUS_INTERFACE_NAME); + if (ctxt->interface == NULL) + goto fail; + + /* Register DBus objects for any pages that were created before we got here */ + guint ix; + for (ix = 0; ix < ctxt->unregistered_pages->len; ix++) + { + guint64 id = g_array_index (ctxt->unregistered_pages, guint64, ix); + register_object (id, ctxt); + } + g_array_remove_range (ctxt->unregistered_pages, 0, + ctxt->unregistered_pages->len); + + return; + +fail: + if (error != NULL) + { + g_critical ("Error hooking up web extension DBus interface: %s", + error->message); + g_clear_error (&error); + } + else + { + g_critical ("Unknown error hooking up web extension DBus interface"); + } +} + +static void +unregister_object (gpointer data, + GDBusConnection *connection) +{ + guint bus_id = GPOINTER_TO_UINT (data); + if (!g_dbus_connection_unregister_object (connection, bus_id)) + g_critical ("Trouble unregistering object"); +} + +static void +on_name_lost (GDBusConnection *connection, + const gchar *name, + Context *ctxt) +{ + if (connection == NULL) + { + g_warning ("Could not initialize DBus interface for WebHelper2 " + "extension; the name %s was lost.", name); + return; + } + + g_slist_foreach (ctxt->bus_ids, (GFunc) unregister_object, connection); +} + +/* Receives the main program's unique DBus name as user data. */ +G_MODULE_EXPORT void +webkit_web_extension_initialize_with_user_data (WebKitWebExtension *extension, + const GVariant *data_from_app) +{ + const gchar *name = g_variant_get_string ((GVariant *) data_from_app, NULL); + + Context *ctxt = g_new0 (Context, 1); + ctxt->extension = extension; + ctxt->unregistered_pages = g_array_new (FALSE, FALSE, sizeof (guint64)); + ctxt->main_program_name = g_strdup (name); + gchar *well_known_name = g_strconcat (name, ".webhelper", NULL); + + g_signal_connect (extension, "page-created", + G_CALLBACK (on_page_created), ctxt); + + g_bus_own_name (G_BUS_TYPE_SESSION, well_known_name, + G_BUS_NAME_OWNER_FLAGS_NONE, + (GBusAcquiredCallback) on_bus_acquired, + NULL, /* name acquired callback */ + (GBusNameLostCallback) on_name_lost, + ctxt, (GDestroyNotify) context_free); + + g_free (well_known_name); +} diff --git a/webhelper/webextensions/wh2jscutil.c b/webhelper/webextensions/wh2jscutil.c new file mode 100644 index 0000000..34f325f --- /dev/null +++ b/webhelper/webextensions/wh2jscutil.c @@ -0,0 +1,65 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/* Copyright 2015 Endless Mobile, Inc. */ + +#include <glib.h> +#include <JavaScriptCore/JavaScript.h> + +#include "wh2jscutil.h" + +G_GNUC_INTERNAL +gboolean +set_object_property (JSContextRef js, + JSObjectRef object, + const gchar *property_name, + JSValueRef property_value, + JSPropertyAttributes flags) +{ + JSValueRef exception = NULL; + JSStringRef property_name_ref = JSStringCreateWithUTF8CString (property_name); + JSObjectSetProperty (js, object, property_name_ref, property_value, flags, + &exception); + JSStringRelease (property_name_ref); + if (exception != NULL) + { + g_critical ("There was a problem setting the property '%s'.", + property_name); + return FALSE; + } + return TRUE; +} + +/* Returns a newly allocated string. */ +G_GNUC_INTERNAL +gchar * +string_ref_to_string (JSStringRef string_ref) +{ + size_t bufsize = JSStringGetMaximumUTF8CStringSize (string_ref); + gchar *string = g_new0 (gchar, bufsize); + JSStringGetUTF8CString (string_ref, string, bufsize); + return string; +} + +G_GNUC_INTERNAL +JSValueRef +string_to_value_ref (JSContextRef js, + const gchar *string) +{ + JSStringRef string_ref = JSStringCreateWithUTF8CString (string); + JSValueRef value_ref = JSValueMakeString (js, string_ref); + /* value_ref owns string_ref now */ + return value_ref; +} + +G_GNUC_INTERNAL +JSValueRef +throw_exception (JSContextRef js, + const gchar *message) +{ + JSValueRef msgval = string_to_value_ref (js, message); + JSValueRef inner_error = NULL; + JSObjectRef exception = JSObjectMakeError (js, 1, &msgval, &inner_error); + if (inner_error != NULL) + return inner_error; + return (JSValueRef) exception; +} diff --git a/webhelper/webextensions/wh2jscutil.h b/webhelper/webextensions/wh2jscutil.h new file mode 100644 index 0000000..af800e5 --- /dev/null +++ b/webhelper/webextensions/wh2jscutil.h @@ -0,0 +1,25 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/* Copyright 2015 Endless Mobile, Inc. */ + +#ifndef WH2_JSC_UTIL_H +#define WH2_JSC_UTIL_H + +#include <glib.h> +#include <JavaScriptCore/JavaScript.h> + +gboolean set_object_property (JSContextRef js, + JSObjectRef object, + const gchar *property_name, + JSValueRef property_value, + JSPropertyAttributes flags); + +gchar *string_ref_to_string (JSStringRef string_ref); + +JSValueRef string_to_value_ref (JSContextRef js, + const gchar *string); + +JSValueRef throw_exception (JSContextRef js, + const gchar *message); + +#endif /* WH2_JSC_UTIL_H */ diff --git a/webhelper/webhelper2.js b/webhelper/webhelper2.js new file mode 100644 index 0000000..b5f87f8 --- /dev/null +++ b/webhelper/webhelper2.js @@ -0,0 +1,500 @@ +// Copyright 2015 Endless Mobile, Inc. + +imports.gi.versions.WebKit2 = '4.0'; + +const Format = imports.format; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const GObject = imports.gi.GObject; +const Lang = imports.lang; +const WebHelper2Private = imports.gi.WebHelper2Private; +const WebKit2 = imports.gi.WebKit2; + +const Config = imports.webhelper_private.config; + +String.prototype.format = Format.format; + +const WH2_URI_SCHEME = 'webhelper'; +const DBUS_WEBVIEW_EXPORT_PATH = '/com/endlessm/webview/'; +const WH2_DBUS_EXTENSION_INTERFACE = '\ + <node> \ + <interface name="com.endlessm.WebHelper2.Translation"> \ + <method name="Translate"/> \ + </interface> \ + </node>'; +const WH2_DBUS_MAIN_PROGRAM_INTERFACE = '\ + <node> \ + <interface name="com.endlessm.WebHelper2.Gettext"> \ + <method name="Gettext"> \ + <arg name="message" type="s" direction="in"/> \ + <arg name="translation" type="s" direction="out"/> \ + </method> \ + <method name="NGettext"> \ + <arg name="message_singular" type="s" direction="in"/> \ + <arg name="message_plural" type="s" direction="in"/> \ + <arg name="number" type="t" direction="in"/> \ + <arg name="translation" type="s" direction="out"/> \ + </method> \ + </interface> \ + </node>'; + +/** + * Namespace: WebHelper2 + * Convenience library for running web applications + * + * WebHelper is a convenience library for displaying web applications inside a + * GTK container running WebKitGTK. + * WebHelper2 is the WebKit2 version. + * + * Although WebKit provides an easy way of communicating from GTK code to + * the in-browser Javascript, through the execute_script() method, it is not so + * easy to communicate the other way around. + * + * WebHelper solves that problem by detecting when the web page navigates to a + * custom action URI. + * The custom URI corresponds to a function that you define using + * <WebHelper.define_web_action()>, and you can pass parameters to the + * function. + * + * Another often-encountered problem is localizing text through the same API as + * your main GTK program. + * WebHelper solves this problem by allowing you to mark strings in your HTML + * page and translating them through a function of your choice when you run + * <WebHelper.translate_html()>. + * It also exposes a *gettext()* function in the client-side Javascript. + */ + +/** + * Class: WebHelper + * Helper object for a WebKit2 web application + * + * Constructor parameters: + * props - a dictionary of construction properties and values (default {}) + * + * The application class for your web application should create <WebHelper> in + * its *vfunc_dbus_register()* implementation, with appropriate + * <well-known-name> and <connection> parameters. + * After that, you can do further setup on it, such as defining web actions, in + * your *vfunc_startup()* implementation. + * + * There is no need to set up specially any web views that you create, unlike + * WebKit1 where you must set up <Application.web_actions_handler()>. + * + * Example: + * > const TestApplication = new Lang.Class({ + * > Name: 'TestApplication', + * > Extends: Gtk.Application, + * > + * > vfunc_dbus_register: function (connection, object_path) { + * > this._webhelper = new WebHelper2.WebHelper({ + * > well_known_name: this.application_id, + * > connection: connection, + * > }); + * > return this.parent(connection, object_path); + * > }, + * > + * > vfunc_startup: function () { + * > this.parent(); + * > + * > this._webhelper.set_gettext(Gettext.dgettext.bind(null, + * > GETTEXT_DOMAIN)); + * > + * > let window = new Gtk.Window(); + * > let webview = new WebKit2.WebView(); + * > webview.connect('load-changed', (webview, event) => { + * > if (event === WebKit2.LoadEvent.FINISHED) + * > this._webhelper.translate_html(webview, null, (obj, res) => { + * > this._webhelper.translate_html_finish(res); + * > window.show_all(); + * > }); + * > }); + * > window.add(webview); + * > webview.load_uri('file:///path/to/my/page.html'); + * > }, + * > + * > vfunc_dbus_unregister: function (connection, object_path) { + * > this.parent(connection, object_path); + * > this._webhelper.unregister(); + * > }, + * >}); + * > + * >let app = new TestApplication({ + * > application_id: 'com.example.SmokeGrinder', + * >}); + * >app.run(ARGV); + */ +const WebHelper = new Lang.Class({ + Name: 'WebHelper', + GTypeName: 'Wh2WebHelper', + Extends: GObject.Object, + Properties: { + /** + * Property: well-known-name + * Well-known bus name owned by the calling program + * + * Type: + * string + * + * This property is required at construction time. + * It must conform to <the rules for well-known bus names at + * http://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names>. + * + * This must be a well-known bus name that your program owns. + * The easiest way to ensure that is to use your application's ID, since + * every application class registers its ID as a bus name. + */ + 'well-known-name': GObject.ParamSpec.string('well-known-name', + 'Well-known name', + 'Well-known bus name owned by the calling program', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + ''), + /** + * Property: connection + * DBus connection owned by the calling program + * + * Type: + * *Gio.DBusConnection* + * + * This property is required at construction time. + * + * This must be a DBus connection object that your program owns. + * The easiest way to ensure that is to use the connection object passed + * in to your application's *vfunc_dbus_register()* function. + */ + 'connection': GObject.ParamSpec.object('connection', 'Connection', + 'DBus connection owned by the calling program', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + Gio.DBusConnection.$gtype), + }, + + _init: function (props={}) { + this._web_actions = {}; + this._gettext = null; + this._ngettext = null; + this._ProxyConstructor = + Gio.DBusProxy.makeProxyWrapper(WH2_DBUS_EXTENSION_INTERFACE); + this.parent(props); + + if (this.well_known_name === '') + throw new Error('The "well-known-name" parameter is required.'); + this._extension_name = this.well_known_name + '.webhelper'; + + // Set up Webkit to load our web extension + let context = WebKit2.WebContext.get_default(); + context.connect('initialize-web-extensions', () => { + let libexec = Gio.File.new_for_path(Config.LIBEXECDIR); + let path = libexec.get_child('webhelper2').get_path(); + + let localpath = GLib.getenv('WEBHELPER_UNINSTALLED_EXTENSION_DIR'); + if (localpath) + path = localpath; + + context.set_web_extensions_directory(path); + context.set_web_extensions_initialization_user_data(new GLib.Variant('s', + this.well_known_name)); + }); + + // Export our own DBus interface + this._dbus_impl = + Gio.DBusExportedObject.wrapJSObject(WH2_DBUS_MAIN_PROGRAM_INTERFACE, + this); + this._dbus_impl.export(this.connection, '/com/endlessm/gettext'); + + // Set up handling for webhelper:// URIs + WebHelper2Private.register_uri_scheme(WH2_URI_SCHEME, + this._on_endless_uri_request.bind(this)); + }, + + _on_endless_uri_request: function (request) { + let uri = request.get_uri(); + + // get the name and parameters for the desired function + let f_call = uri.substr((WH2_URI_SCHEME + '://').length).split('?'); + let function_name = decodeURI(f_call[0]); + + if (!this._web_actions.hasOwnProperty(function_name)) + throw new Error(('Undefined WebHelper action "%s". Did you define it with ' + + 'WebHelper.Application.define_web_action()?').format(function_name)); + + let parameters = {}; + if (f_call[1]) { + // there are parameters + let params = f_call[1].split('&'); + params.forEach(function (entry) { + let param = entry.split('='); + + if (param.length == 2) { + param[0] = decodeURIComponent(param[0]); + param[1] = decodeURIComponent(param[1]); + // and now we add it... + parameters[param[0]] = param[1]; + } + }); + } + + (this._web_actions[function_name].bind(this))(parameters); + + // Don't call request.finish(), because we don't want to finish the + // action, which would involve loading a new page. The request dies + // if we return from this function without calling ref() or finish() + // on it. + }, + + // DBus implementations + + Gettext: function (string) { + return this._gettext(string); + }, + + NGettext: function (singular, plural, number) { + return this._ngettext(singular, plural, number); + }, + + // Public API + + /** + * Method: set_gettext + * Define function which translates text + * + * Parameters: + * gettext_func - a function, or null + * + * When you plan to translate text in your web application, set this + * property to the translation function. + * The function must take one parameter, a string, and also return a + * string. + * The canonical example is gettext(). + * + * This function will be called with each string to translate when you call + * <translate_html()>. + * The function is also made available directly to the browser-side + * Javascript as *gettext()*, a property of the global object. + * + * Pass null for _gettext_func_ to unset the translation function. + * + * If this function has not been called, or has most recently been called + * with null, then it is illegal to call <translate_html()>. + * + * Example: + * > const Gettext = imports.gettext; + * > const GETTEXT_DOMAIN = 'smoke-grinder'; + * > + * > webhelper.set_gettext(Gettext.dgettext.bind(null, GETTEXT_DOMAIN)); + */ + set_gettext: function (gettext_func) { + if (gettext_func !== null && typeof gettext_func !== 'function') + throw new Error('The translation function must be a function, or ' + + 'null.'); + this._gettext = gettext_func; + }, + + /** + * Method: get_gettext + * Retrieve the currently set translation function + * + * Returns: + * the translation function previously set with <set_gettext()>, or null + * if none is currently set. + */ + get_gettext: function () { + return this._gettext; + }, + + /** + * Method: set_ngettext + * Define function which translates singular/plural text + * + * Parameters: + * ngettext_func - a function, or null + * + * When you plan to translate text in your web application, set this + * property to the translation function. + * The function must take three parameters: a string singular message, a + * string plural message, and a number for which to generate the message. + * The function must return a string. + * The canonical example is ngettext(). + * + * This function is made available directly to the browser-side Javascript + * as *ngettext()*, a property of the global object. + * + * Pass null for _ngettext_func_ to unset the translation function. + * + * If this function has not been called, or has most recently been called + * with null, then it is illegal to call *ngettext()* in the client-side + * Javascript. + * + * Example: + * > const WebHelper2 = imports.webhelper2; + * > const Gettext = imports.gettext; + * > const GETTEXT_DOMAIN = 'smoke-grinder'; + * > + * > let webhelper = new WebHelper2.WebHelper('com.example.SmokeGrinder'); + * > webhelper.set_gettext(Gettext.dngettext.bind(null, GETTEXT_DOMAIN)); + */ + set_ngettext: function (ngettext_func) { + if (ngettext_func !== null && typeof ngettext_func !== 'function') + throw new Error('The translation function must be a function, or ' + + 'null.'); + this._ngettext = ngettext_func; + }, + + /** + * Method: get_ngettext + * Retrieve the currently set singular/plural translation function + * + * Returns: + * the translation function previously set with <set_ngettext()>, or null + * if none is currently set. + */ + get_ngettext: function () { + return this._ngettext; + }, + + /** + * Method: translate_html + * Translate the HTML page in a webview asynchronously + * + * Parameters: + * webview - a *WebKit2.WebView* with HTML loaded + * cancellable - a *Gio.Cancellable*, or null + * callback - a function that takes two parameters: this <WebHelper> + * object, and a result object; or null if you don't want a callback + * + * Perform translation on all HTML elements marked with name="translatable" + * in the HTML document displaying in _webview_. + * The translation will be performed using the function you have set using + * <set_gettext()>. + * + * When the translation is finished, _callback_ will be called. + * You can get the result of the operation by calling + * <translate_html_finish()> with the _result_ object passed to _callback_. + * + * You can optionally pass _cancellable_ if you want to be able to cancel + * the operation. + * + * Example: + * > webview.connect('load-changed', (webview, event) => { + * > if (event === WebKit2.LoadEvent.FINISHED) + * > webhelper.translate_html(webview, null, (obj, res) => { + * > webhelper.translate_html_finish(res); + * > // Translation done, show the page + * > webview.show_all(); + * > }); + * > }); + */ + translate_html: function (webview, cancellable, callback) { + let task = { callback: callback }; + // Wait for the DBus interface to appear on the bus + task.watch_id = Gio.DBus.watch_name(Gio.BusType.SESSION, + this._extension_name, Gio.BusNameWatcherFlags.NONE, + (connection, name, owner) => { + // name appeared + let webview_object_path = DBUS_WEBVIEW_EXPORT_PATH + + webview.get_page_id(); + let proxy = new this._ProxyConstructor(connection, + this._extension_name, webview_object_path); + if (cancellable) + proxy.TranslateRemote(cancellable, + this._translate_callback.bind(this, task)); + else + proxy.TranslateRemote(this._translate_callback.bind(this, + task)); + }, + null); // do nothing when name vanishes + return task; + }, + + _translate_callback: function (task, result, error) { + Gio.DBus.unwatch_name(task.watch_id); + if (error) + task.error = error; + if (task.callback) + task.callback(this, task); + }, + + /** + * Method: translate_html_finish + * Get the result of <translate_html()> + * + * Parameters: + * res - result object passed to your callback + * + * Finishes an operation started by <translate_html()>. + * If the operation ended in an error, this function will throw that error. + */ + translate_html_finish: function (res) { + if (res.error) + throw res.error; + }, + + /** + * Method: define_web_action + * Define an action that may be invoked from a WebView + * + * Parameters: + * name - a string, which must be a valid URI location. + * implementation - a function (see Callback Parameters below.) + * + * Callback Parameters: + * dict - object containing properties corresponding to the query + * parameters that the web action was called with + * + * Sets up an action that may be invoked from an HTML document inside a + * WebView, or from the in-browser Javascript environment inside a WebView. + * If you set up an action "setVolume" as follows: + * > app.define_web_action('setVolume', function(dict) { ... }); + * Then you can invoke the action inside the HTML document, e.g. as the + * target of a link, as follows: + * > <a href="endless://setVolume?volume=11">This one goes to 11</a> + * Or from the in-browser Javascript, by navigating to the action URI, as + * follows: + * > window.location.href = 'endless://setVolume?volume=11'; + * + * In both cases, the function would then be called with the _dict_ + * parameter equal to + * > { "volume": "11" } + * + * If an action called _name_ is already defined, the new _implementation_ + * replaces the old one. + */ + define_web_action: function (name, implementation) { + if (typeof implementation !== 'function') { + throw new Error('The implementation of a web action must be a ' + + 'function.'); + } + this._web_actions[name] = implementation; + }, + + /** + * Method: define_web_actions + * Define several web actions at once + * + * Parameters: + * dict - an object, with web action names as property names, and their + * implementations as values + * + * Convenience method to define more than one web action at once. + * Calls <define_web_action()> on each property of _dict_. + * + * *Note* This API is Javascript-only. It will not be implemented in C. + */ + define_web_actions: function (dict) { + Object.keys(dict).forEach((key) => + this.define_web_action(key, dict[key])); + }, + + /** + * Method: unregister + * Break the connection to WebKit + * + * Breaks the connection to all webviews and removes all DBus objects. + * You should call this in your application's *vfunc_dbus_unregister()* + * implementation. + * + * After this function has been called, no WebHelper functionality will + * work. + */ + unregister: function () { + this._dbus_impl.unexport_from_connection(this.connection); + }, +}); diff --git a/webhelper/webhelper_private/config.js.in b/webhelper/webhelper_private/config.js.in new file mode 100644 index 0000000..f9d87cb --- /dev/null +++ b/webhelper/webhelper_private/config.js.in @@ -0,0 +1 @@ +const LIBEXECDIR = '%libexecdir%'; |