diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | Makefile.am | 22 | ||||
-rw-r--r-- | configure.ac | 3 | ||||
-rw-r--r-- | jasmine.json | 3 | ||||
-rw-r--r-- | test/Makefile.am.inc | 1 | ||||
-rw-r--r-- | test/smoke-tests/webhelper/webview2.js | 47 | ||||
-rw-r--r-- | test/webhelper/testWebActions2.js | 121 | ||||
-rw-r--r-- | webhelper/lib/wh2private.c | 36 | ||||
-rw-r--r-- | webhelper/lib/wh2private.h | 20 | ||||
-rw-r--r-- | webhelper/webhelper2.js | 113 |
10 files changed, 365 insertions, 3 deletions
@@ -6,6 +6,8 @@ test/smoke-tests/hello test/smoke-tests/images/credits.gresource Endless-0.gir Endless-0.typelib +WebHelper2Private-1.0.gir +WebHelper2Private-1.0.typelib endless/eosresource.c endless/eosresource-private.h tools/eos-application-manifest/eos-application-manifest diff --git a/Makefile.am b/Makefile.am index 5dc8696..0e54fe6 100644 --- a/Makefile.am +++ b/Makefile.am @@ -100,6 +100,18 @@ dist_webhelper_private_DATA = webhelper/webhelper_private/config.js EOS_JS_COVERAGE_FILES = $(webhelper_sources) +## Workaround for https://bugs.webkit.org/show_bug.cgi?id=116672 +## When that is solved, we can eliminate this private library and go back to +## using pure Javascript in WebHelper. +lib_LTLIBRARIES += libwebhelper2private.la +libwebhelper2private_la_SOURCES = \ + webhelper/lib/wh2private.c \ + webhelper/lib/wh2private.h \ + $(NULL) +libwebhelper2private_la_CPPFLAGS = @WEBHELPER2_PRIVATE_CFLAGS@ +libwebhelper2private_la_LIBADD = @WEBHELPER2_PRIVATE_LIBS@ +libwebhelper2private_la_LDFLAGS = -avoid-version + webhelper2extensionsdir = $(libexecdir)/webhelper2 webhelper2extensions_LTLIBRARIES = wh2extension.la wh2extension_la_SOURCES = webhelper/webextensions/wh2extension.c @@ -132,6 +144,16 @@ Endless_@EOS_SDK_API_VERSION@_gir_FILES = $(introspection_sources) Endless_@EOS_SDK_API_VERSION@_gir_EXPORT_PACKAGES = @EOS_SDK_API_NAME@ INTROSPECTION_GIRS += Endless-@EOS_SDK_API_VERSION@.gir +WebHelper2Private-1.0.gir: libwebhelper2private.la +WebHelper2Private_1_0_gir_INCLUDES = GObject-2.0 GLib-2.0 WebKit2-4.0 +WebHelper2Private_1_0_gir_SCANNERFLAGS = \ + --identifier-prefix=Wh2 \ + --symbol-prefix=wh2 \ + $(NULL) +WebHelper2Private_1_0_gir_LIBS = libwebhelper2private.la +WebHelper2Private_1_0_gir_FILES = $(libwebhelper2private_la_SOURCES) +INTROSPECTION_GIRS += WebHelper2Private-1.0.gir + girdir = $(datadir)/gir-1.0 gir_DATA = $(INTROSPECTION_GIRS) diff --git a/configure.ac b/configure.ac index fe37a8b..e6f140f 100644 --- a/configure.ac +++ b/configure.ac @@ -221,6 +221,9 @@ PKG_CHECK_MODULES([WEBHELPER2_EXTENSION], [ $GLIB_REQUIREMENT $GOBJECT_REQUIREMENT $WEBKIT2_REQUIREMENT]) +PKG_CHECK_MODULES([WEBHELPER2_PRIVATE], [ + $GLIB_REQUIREMENT + $WEBKIT2_REQUIREMENT]) # Check installed GIRs for webhelper JS module EOS_CHECK_GJS_GIR([GLib], [2.0]) diff --git a/jasmine.json b/jasmine.json index 4d2ef8b..9c7b40c 100644 --- a/jasmine.json +++ b/jasmine.json @@ -14,6 +14,7 @@ "environment": { "GI_TYPELIB_PATH": ".", "LD_LIBRARY_PATH": ".libs", - "XDG_CONFIG_HOME": "/tmp" + "XDG_CONFIG_HOME": "/tmp", + "WEBHELPER_UNINSTALLED_EXTENSION_DIR": ".libs" } } diff --git a/test/Makefile.am.inc b/test/Makefile.am.inc index 7e10953..411d006 100644 --- a/test/Makefile.am.inc +++ b/test/Makefile.am.inc @@ -46,6 +46,7 @@ javascript_tests = \ test/webhelper/testTranslate.js \ test/webhelper/testTranslate2.js \ test/webhelper/testWebActions.js \ + test/webhelper/testWebActions2.js \ test/webhelper/testUpdateFontSize.js \ test/endless/testCustomContainer.js \ test/endless/testTopbarNavButton.js \ diff --git a/test/smoke-tests/webhelper/webview2.js b/test/smoke-tests/webhelper/webview2.js index 604819c..ec029c9 100644 --- a/test/smoke-tests/webhelper/webview2.js +++ b/test/smoke-tests/webhelper/webview2.js @@ -2,6 +2,7 @@ const Endless = imports.gi.Endless; const Gettext = imports.gettext; +const Gtk = imports.gi.Gtk; const Lang = imports.lang; const WebHelper2 = imports.webhelper2; const WebKit2 = imports.gi.WebKit2; @@ -26,6 +27,17 @@ body { \ <body> \ <h1>First page</h1> \ \ +<p><a href="webhelper://moveToPage?name=page2">Move to page 2</a></p> \ +\ +<p><a \ +href="webhelper://showMessageFromParameter?msg=This%20is%20a%20message%20from%20the%20URL%20parameter">Show \ +message from parameter in this URL</a></p> \ +\ +<form action="webhelper://showMessageFromParameter"> \ +<input name="msg" value="I am in a form!"/> \ +<input type="submit" value="Show message using a form"/> \ +</form> \ +\ <p><a href="http://wikipedia.org">Regular link to a Web site</a></p> \ \ <p>This is text that will be italicized: <span name="translatable">Hello, \ @@ -50,6 +62,10 @@ const TestApplication = new Lang.Class({ this.parent(); this._webhelper.set_gettext((s) => s.italics()); + this._webhelper.define_web_actions({ + moveToPage: this.moveToPage.bind(this), + showMessageFromParameter: this.showMessageFromParameter.bind(this), + }); this._webview = new WebKit2.WebView(); this._webview.connect('load-changed', (webview, event) => { @@ -58,7 +74,14 @@ const TestApplication = new Lang.Class({ this._webhelper.translate_html_finish(res); }); }); - this._webview.load_html(TEST_HTML, 'file://'); + this._webview.load_html(TEST_HTML, null); + + this._page2 = new Gtk.Grid(); + let back_button = new Gtk.Button({ label: 'Go back to page 1' }); + back_button.connect('clicked', () => { + this._pm.visible_child_name = 'page1'; + }); + this._page2.add(back_button); this._window = new Endless.Window({ application: this, @@ -66,7 +89,10 @@ const TestApplication = new Lang.Class({ }); this._pm = this._window.page_manager; + this._pm.set_transition_type(Gtk.StackTransitionType.CROSSFADE); this._pm.add(this._webview, { name: 'page1' }); + this._pm.add(this._page2, { name: 'page2' }); + this._pm.visible_child_name = 'page1'; this._window.show_all(); }, @@ -75,6 +101,25 @@ const TestApplication = new Lang.Class({ this.parent(connection, object_path); this._webhelper.unregister(); }, + + // WEB ACTIONS + + // dict['name'] is the name of the page to move to + moveToPage: function (dict) { + this._pm.visible_child_name = dict['name']; + }, + + // dict['msg'] is the message to display + showMessageFromParameter: function (dict) { + let dialog = new Gtk.MessageDialog({ + buttons: Gtk.ButtonsType.CLOSE, + message_type: Gtk.MessageType.INFO, + text: dict['msg'], + transient_for: this._window, + }); + dialog.run(); + dialog.destroy(); + }, }); let app = new TestApplication({ diff --git a/test/webhelper/testWebActions2.js b/test/webhelper/testWebActions2.js new file mode 100644 index 0000000..5af5e2b --- /dev/null +++ b/test/webhelper/testWebActions2.js @@ -0,0 +1,121 @@ +const Gio = imports.gi.Gio; +const Gtk = imports.gi.Gtk; +const Lang = imports.lang; +const Mainloop = imports.mainloop; +const WebHelper2 = imports.webhelper2; +const WebKit2 = imports.gi.WebKit2; + +const WELL_KNOWN_NAME = 'com.endlessm.WebHelper.testWebActions2'; + +Gtk.init(null); + +describe('WebKit2 actions bindings', function () { + let owner_id, connection, webview, webhelper, web_action_spy; + + beforeAll(function (done) { + owner_id = Gio.DBus.own_name(Gio.BusType.SESSION, WELL_KNOWN_NAME, + Gio.BusNameOwnerFlags.NONE, + null, // bus acquired + (con, name) => { // name acquired + connection = con; + done(); + }, + null); // name lost + }); + + afterAll(function () { + Gio.DBus.unown_name(owner_id); + }); + + function run_loop(action_to_test) { + let string = '<html><head><meta http-equiv="refresh" content="0;url=' + + action_to_test + '"></head><body></body></html>'; + webview.load_html(string, null); + Mainloop.run('webhelper2'); + } + + beforeEach(function () { + webhelper = new WebHelper2.WebHelper({ + well_known_name: WELL_KNOWN_NAME, + connection: connection, + }); + webview = new WebKit2.WebView(); + web_action_spy = jasmine.createSpy('web_action_spy').and.callFake(() => + Mainloop.quit('webhelper2')); + webhelper.define_web_action('action', web_action_spy); + }); + + afterEach(function () { + webhelper.unregister(); + }); + + it('calls a web action', function () { + run_loop('webhelper://action'); + expect(web_action_spy).toHaveBeenCalled(); + }); + + it('calls a web action with a parameter', function () { + run_loop('webhelper://action?param=value'); + expect(web_action_spy).toHaveBeenCalledWith(jasmine.objectContaining({ + param: 'value', + })); + }); + + it('calls a web action with many parameters', function () { + run_loop('webhelper://action?first=thefirst&second=thesecond&third=thethird'); + expect(web_action_spy).toHaveBeenCalledWith(jasmine.objectContaining({ + first: 'thefirst', + second: 'thesecond', + third: 'thethird', + })); + }); + + it('uri-decodes parameter names', function () { + run_loop('webhelper://action?p%C3%A4r%C3%A4m%F0%9F%92%A9=value'); + expect(web_action_spy).toHaveBeenCalledWith(jasmine.objectContaining({ + 'päräm💩': 'value', + })); + }); + + it('uri-decodes parameter values', function () { + run_loop('webhelper://action?param=v%C3%A1lu%C3%A9%F0%9F%92%A9'); + expect(web_action_spy).toHaveBeenCalledWith(jasmine.objectContaining({ + param: 'válué💩', + })); + }); + + // This is commented out because GJS cannot catch exceptions across FFI + // interfaces (e.g. in GObject callbacks.) + xit('raises an exception on a nonexistent action instead of calling it', function () { + expect(function () { + run_loop('webhelper://nonexistentAction?param=value'); + }).toThrow(); + }); + + it('calls a web action with a blank parameter', function () { + run_loop('webhelper://action?param='); + expect(web_action_spy).toHaveBeenCalledWith(jasmine.objectContaining({ + param: '', + })); + }); + + it('uri-decodes web action names', function () { + webhelper.define_web_action('äction💩Quit', web_action_spy); + run_loop('webhelper://%C3%A4ction%F0%9F%92%A9Quit'); + expect(web_action_spy).toHaveBeenCalled(); + }); + + it('can define more than one action with define_web_actions()', function () { + webhelper.define_web_actions({ + action2: web_action_spy, + }); + run_loop('webhelper://action2'); + expect(web_action_spy).toBeTruthy(); + }); + + it('complains when defining an action that is not a function', function () { + expect(function () { + webhelper.define_web_action('badAction', 'not a function'); + }).toThrow(); + }); +}); 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/webhelper2.js b/webhelper/webhelper2.js index beb0333..2897a61 100644 --- a/webhelper/webhelper2.js +++ b/webhelper/webhelper2.js @@ -2,14 +2,19 @@ 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> \ @@ -35,7 +40,17 @@ const WH2_DBUS_MAIN_PROGRAM_INTERFACE = '\ * GTK container running WebKitGTK. * WebHelper2 is the WebKit2 version. * - * One often-encountered problem is localizing text through the same API as + * 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 @@ -146,6 +161,7 @@ const WebHelper = new Lang.Class({ }, _init: function (props={}) { + this._web_actions = {}; this._gettext = null; this._ProxyConstructor = Gio.DBusProxy.makeProxyWrapper(WH2_DBUS_EXTENSION_INTERFACE); @@ -175,6 +191,45 @@ const WebHelper = new Lang.Class({ 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 @@ -306,6 +361,62 @@ const WebHelper = new Lang.Class({ }, /** + * 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 * |