diff options
-rw-r--r-- | .gitignore | 18 | ||||
-rw-r--r-- | Makefile.am | 76 | ||||
-rw-r--r-- | configure.ac | 14 | ||||
-rw-r--r-- | docs/reference/webhelper/eos.css | 2 | ||||
-rw-r--r-- | endless/Makefile.am | 2 | ||||
-rw-r--r-- | jasmine.json | 14 | ||||
-rw-r--r-- | m4/eos-gir.m4 | 90 | ||||
-rw-r--r-- | test/Makefile.am.inc | 3 | ||||
-rw-r--r-- | test/smoke-tests/webhelper/webview2.js | 147 | ||||
-rw-r--r-- | test/webhelper/testTranslate2.js | 178 | ||||
-rw-r--r-- | test/webhelper/testWebActions2.js | 121 | ||||
-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 |
19 files changed, 1812 insertions, 36 deletions
@@ -6,10 +6,13 @@ 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 tools/eos-json-extractor/eos-json-extractor +webhelper/webhelper_private/config.js *.py[cod] @@ -100,21 +103,6 @@ stamp* # Autogenerated gir-doc /docs/reference/endless-js -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs -.installed.cfg -lib -lib64 - # Installer logs pip-log.txt diff --git a/Makefile.am b/Makefile.am index 5c877da..0a918ac 100644 --- a/Makefile.am +++ b/Makefile.am @@ -31,6 +31,9 @@ DISTCHECK_CONFIGURE_FLAGS = --enable-gtk-doc --enable-gir-doc --enable-js-doc CLEANFILES = DISTCLEANFILES = +# Other targets to add to +lib_LTLIBRARIES = + # Make sure that 'make dist' includes documentation if CAN_MAKE_DIST dist-hook:: @@ -43,6 +46,27 @@ dist-hook:: @false endif +# # # SUBSTITUTED FILES # # # +# These files need to be filled in with make variables + +subst = $(SED) \ + -e 's,%libexecdir%,$(libexecdir),g' \ + $(NULL) + +subst_files = \ + webhelper/webhelper_private/config.js \ + $(NULL) + +$(subst_files): %: %.in Makefile + $(AM_V_GEN)$(MKDIR_P) $(@D) && \ + rm -f $@ $@.tmp && \ + $(subst) $< > $@.tmp && \ + chmod a-w $@.tmp && \ + mv $@.tmp $@ + +CLEANFILES += $(subst_files) +EXTRA_DIST += $(patsubst %,%.in,$(subst_files)) + # # # LIBRARY # # # # Main Open Endless SDK library @@ -59,9 +83,45 @@ pkgconfigdir = $(libdir)/pkgconfig pkgconfig_DATA = @EOS_SDK_API_NAME@.pc DISTCLEANFILES += @EOS_SDK_API_NAME@.pc -# SDK sublibraries +# # # WEBHELPER LIBRARY # # # + +webhelper_sources = \ + webhelper/webhelper.js \ + webhelper/webhelper2.js \ + $(NULL) + gjsmodulesdir = $(datadir)/gjs-1.0 -include $(top_srcdir)/webhelper/Makefile.am.inc +webhelperdir = $(gjsmodulesdir) +webhelper_privatedir = $(webhelperdir)/webhelper_private +dist_webhelper_DATA = \ + $(webhelper_sources) \ + $(NULL) +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 \ + webhelper/webextensions/wh2jscutil.c \ + webhelper/webextensions/wh2jscutil.h \ + $(NULL) +wh2extension_la_CPPFLAGS = @WEBHELPER2_EXTENSION_CFLAGS@ +wh2extension_la_LIBADD = @WEBHELPER2_EXTENSION_LIBS@ +wh2extension_la_LDFLAGS = -module -avoid-version -no-undefined # # # INTROSPECTION FILES # # # @@ -70,7 +130,6 @@ INTROSPECTION_GIRS = INTROSPECTION_SCANNER_ARGS = --add-include-path=$(srcdir) --warn-all INTROSPECTION_COMPILER_ARGS = --includedir=$(srcdir) -if HAVE_INTROSPECTION introspection_sources = \ $(filter-out %-private.h, $(endless_library_sources)) \ $(endless_public_installed_headers) \ @@ -89,6 +148,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) @@ -96,7 +165,6 @@ typelibdir = $(libdir)/girepository-1.0 typelib_DATA = $(INTROSPECTION_GIRS:.gir=.typelib) CLEANFILES += $(gir_DATA) $(typelib_DATA) -endif # # # GOBJECT INTROSPECTION DOCUMENTATION # # # diff --git a/configure.ac b/configure.ac index 4bc10e6..e6f140f 100644 --- a/configure.ac +++ b/configure.ac @@ -83,6 +83,7 @@ GOBJECT_REQUIREMENT="gobject-2.0" GIO_REQUIREMENT="gio-2.0" GTK_REQUIREMENT="gtk+-3.0 >= 3.10" JSON_GLIB_REQUIREMENT="json-glib-1.0 >= 0.12" +WEBKIT2_REQUIREMENT="webkit2gtk-4.0" # These go into the pkg-config file as Requires: and Requires.private: # (Generally, use Requires.private: instead of Requires:) EOS_REQUIRED_MODULES= @@ -216,7 +217,18 @@ AC_SUBST([JASMINE_REPORT_ARGUMENT]) PKG_CHECK_MODULES([EOS_SDK], [ $EOS_REQUIRED_MODULES $EOS_REQUIRED_MODULES_PRIVATE]) - +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]) +EOS_CHECK_GJS_GIR([WebKit], [3.0]) +EOS_CHECK_GJS_GIR([WebKit2], [4.0]) # Code coverage reports support EOS_COVERAGE_REPORT([c js]) diff --git a/docs/reference/webhelper/eos.css b/docs/reference/webhelper/eos.css index 7c3a4b2..0bed010 100644 --- a/docs/reference/webhelper/eos.css +++ b/docs/reference/webhelper/eos.css @@ -41,6 +41,6 @@ p { .prettyprint *, .CBody pre, .CDLEntry { - font-family: "DejaVu Sans Mono"; + font-family: "DejaVu Sans Mono", Menlo, monospace; font-size: 12pt; }
\ No newline at end of file diff --git a/endless/Makefile.am b/endless/Makefile.am index 2cdca90..52f6f85 100644 --- a/endless/Makefile.am +++ b/endless/Makefile.am @@ -50,7 +50,7 @@ endless_library_sources = \ endless/eosflexygrid.c endless/eosflexygridcell.c endless/eosflexygrid-private.h # Endless GUI library -lib_LTLIBRARIES = libendless-@EOS_SDK_API_VERSION@.la +lib_LTLIBRARIES += libendless-@EOS_SDK_API_VERSION@.la libendless_@EOS_SDK_API_VERSION@_la_SOURCES = \ $(endless_public_installed_headers) \ $(endless_private_installed_headers) \ diff --git a/jasmine.json b/jasmine.json index ff0e666..9c7b40c 100644 --- a/jasmine.json +++ b/jasmine.json @@ -1,10 +1,20 @@ { "include_paths": ["webhelper", "."], "options": "--verbose", - "spec_files": ["test/webhelper", "test/tools/eos-application-manifest"], + "spec_files": [ + "test/endless", + "test/webhelper", + "test/tools/eos-application-manifest" + ], + "exclude": [ + "test/webhelper/testTranslate.js", + "test/webhelper/testUpdateFontSize.js", + "test/webhelper/testWebActions.js" + ], "environment": { "GI_TYPELIB_PATH": ".", "LD_LIBRARY_PATH": ".libs", - "XDG_CONFIG_HOME": "/tmp" + "XDG_CONFIG_HOME": "/tmp", + "WEBHELPER_UNINSTALLED_EXTENSION_DIR": ".libs" } } diff --git a/m4/eos-gir.m4 b/m4/eos-gir.m4 new file mode 100644 index 0000000..f598ca4 --- /dev/null +++ b/m4/eos-gir.m4 @@ -0,0 +1,90 @@ +dnl Copyright 2013 Endless Mobile, Inc. +dnl +dnl Macros to check for GObject introspection libraries + +# EOS_PROG_GJS +# ------------ +# Checks for the presence of GJS in the path. Issues an error +# if it is not. + +AC_DEFUN_ONCE([EOS_PROG_GJS], [ + AC_PATH_PROG([GJS], [gjs], [notfound]) + AS_IF([test "x$GJS" = "xnotfound"], + [AC_MSG_ERROR([GJS is required, but was not found. If GJS is installed, try passing +its path in an environment variable as GJS=/path/to/gjs.])]) +]) + +# _EOS_GJS_IFELSE(program, [action-if-true], [action-if-false]) +# ------------------------------------------------------------- +# Comparable to AC_RUN_IFELSE(), but runs the program using GJS +# instead of trying to compile it and link it. + +AC_DEFUN([_EOS_GJS_IFELSE], [ + AC_REQUIRE([EOS_PROG_GJS]) + echo "$1" >conftest.js + $GJS conftest.js >/dev/null 2>&1 + AS_IF([test $? -eq 0], [$2], [$3]) +]) + +# EOS_CHECK_GJS_GIR(<module>, [<version>]) +# ------------------------------------ +# Example: +# EOS_CHECK_GJS_GIR([Gtk], [3.0]) +# +# Check that the GIR <module> is importable in GJS. The API +# version must be at least <version>, if given. Note that the +# API version is different from the release version; GTK +# currently has API version 3.0, but that could mean any +# release from the 3.0, 3.2, 3.4,... series. To check for +# specific API that was added in a later version, use +# EOS_CHECK_GJS_GIR_API. + +AC_DEFUN([EOS_CHECK_GJS_GIR], [ + AS_IF([test -z "$2"], [ + AC_MSG_CHECKING([for $1]) + _EOS_GJS_IFELSE([const Library = imports.gi.$1;], + [AC_MSG_RESULT([yes])], + [AC_MSG_FAILURE([no])] + ) + ], [ + AC_MSG_CHECKING([for version $2 of $1]) + _EOS_GJS_IFELSE([ + imports.gi.versions@<:@\"$1\"@:>@ = \"$2\"; + const Library = imports.gi.$1; + ], + [AC_MSG_RESULT([yes])], + [ + AC_MSG_RESULT([no]) + GIRNAME="gir1.2-m4_tolower($1)-$2" + AC_MSG_ERROR([You do not have at least API version $2 of +the GObject Introspection bindings for the $1 library. +If on Ubuntu, try installing the '$GIRNAME' package.]) + ] + ) + ]) +]) + +# EOS_CHECK_GJS_GIR_API(<module>, <symbol>) +# ----------------------------------------- +# Example: +# EOS_CHECK_GJS_GIR_API([Gtk], [ListBox]) +# +# Check that <symbol> is defined inside the GIR <module> and +# is discoverable (not undefined) in GJS. + +AC_DEFUN([EOS_CHECK_GJS_GIR_API], [ + AC_MSG_CHECKING([for $1.$2]) + _EOS_GJS_IFELSE([ + const Library = imports.gi.$1; + if(typeof Library.$2 === 'undefined') + throw 1; + ], + [AC_MSG_RESULT([yes])], + [ + AC_MSG_RESULT([no]) + AC_MSG_ERROR([Your GObject Introspection bindings for +the $1 library do not define the symbol $1.$2. +Perhaps you need a newer version of the library?]) + ]) +]) + diff --git a/test/Makefile.am.inc b/test/Makefile.am.inc index 11e4dea..411d006 100644 --- a/test/Makefile.am.inc +++ b/test/Makefile.am.inc @@ -44,7 +44,9 @@ EXTRA_DIST += \ javascript_tests = \ test/tools/eos-application-manifest/testInit.js \ 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 \ @@ -78,4 +80,5 @@ TESTS_ENVIRONMENT = \ export GI_TYPELIB_PATH="$(top_builddir)$${GI_TYPELIB_PATH:+:$$GI_TYPELIB_PATH}"; \ export LD_LIBRARY_PATH="$(top_builddir)/.libs$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH}"; \ export XDG_CONFIG_HOME=`mktemp -d $${TMPDIR:-/tmp}/sdktestconfig.XXXXXXXX`; \ + export WEBHELPER_UNINSTALLED_EXTENSION_DIR="$(top_builddir)/.libs"; \ $(NULL) diff --git a/test/smoke-tests/webhelper/webview2.js b/test/smoke-tests/webhelper/webview2.js new file mode 100644 index 0000000..f50c42d --- /dev/null +++ b/test/smoke-tests/webhelper/webview2.js @@ -0,0 +1,147 @@ +// Copyright 2015 Endless Mobile, Inc. + +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; + +const TEST_APPLICATION_ID = 'com.endlessm.example.test-webview2'; +const TEST_HTML = '\ +<html> \ +<head> \ +<title>First page</title> \ +<style> \ +p, form { \ + width: 50%; \ + padding: 1em; \ + background: #FFFFFF; \ +} \ +body { \ + background: #EEEEEE; \ +} \ +</style> \ +</head> \ +\ +<body> \ +<script type="text/javascript"> \ + window.ngettextButton = (function () { \ + var times = 0; \ + return function () { \ + times++; \ + var message = ngettext("You have clicked me __ time", "You have clicked me __ times", times); \ + alert(message.replace("__", times.toString())); \ + }; \ + })(); \ +</script> \ +<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, \ +world!</span></p> \ +\ +<p> \ + <button onclick="alert(gettext(\'I came from gettext\'));"> \ + Click me to use gettext \ + </button> \ + <button onclick="ngettextButton();">Click me to use ngettext</button> \ +</p> \ +\ +</body> \ +</html>'; + +const TestApplication = new Lang.Class({ + Name: 'TestApplication', + Extends: Endless.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((s) => s.italics()); + this._webhelper.set_ngettext((s, p, n) => + '** ' + (n == 1 ? s : p) + ' **'); + 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) => { + if (event === WebKit2.LoadEvent.FINISHED) + this._webhelper.translate_html(webview, null, (obj, res) => { + this._webhelper.translate_html_finish(res); + }); + }); + 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, + border_width: 16, + }); + + 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(); + }, + + vfunc_dbus_unregister: function (connection, object_path) { + 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({ + application_id: TEST_APPLICATION_ID, +}); +app.run(ARGV); diff --git a/test/webhelper/testTranslate2.js b/test/webhelper/testTranslate2.js new file mode 100644 index 0000000..b34c4cf --- /dev/null +++ b/test/webhelper/testTranslate2.js @@ -0,0 +1,178 @@ +// Copyright 2015 Endless Mobile, Inc. + +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.testTranslate2'; + +Gtk.init(null); + +describe('WebHelper2 translator', function () { + let webhelper, owner_id, connection; + + 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); + }); + + beforeEach(function () { + webhelper = new WebHelper2.WebHelper({ + well_known_name: WELL_KNOWN_NAME, + connection: connection, + }); + }); + + afterEach(function () { + webhelper.unregister(); + }); + + it('complains about a bad gettext function', function () { + expect(function () { + webhelper.set_gettext('I am not a function'); + }).toThrow(); + }); + + it('gets and sets the gettext function', function () { + let translation_function = (s) => s; + webhelper.set_gettext(translation_function); + expect(webhelper.get_gettext()).toBe(translation_function); + }); + + it('has a null gettext function by default', function () { + expect(webhelper.get_gettext()).toBeNull(); + }); + + it('can remove the gettext function by setting null', function () { + webhelper.set_gettext((s) => s); + expect(webhelper.get_gettext()).not.toBeNull(); + webhelper.set_gettext(null); + expect(webhelper.get_gettext()).toBeNull(); + }); + + it('complains about a bad ngettext function', function () { + expect(function () { + webhelper.set_ngettext('I am not a function'); + }).toThrow(); + }); + + it('gets and sets the ngettext function', function () { + let translation_function = (s, p, n) => n == 1 ? s : p; + webhelper.set_ngettext(translation_function); + expect(webhelper.get_ngettext()).toBe(translation_function); + }); + + it('has a null ngettext function by default', function () { + expect(webhelper.get_ngettext()).toBeNull(); + }); + + it('can remove the ngettext function by setting null', function () { + webhelper.set_ngettext((s, p, n) => n == 1 ? s : p); + expect(webhelper.get_ngettext()).not.toBeNull(); + webhelper.set_ngettext(null); + expect(webhelper.get_ngettext()).toBeNull(); + }); + + describe('translating a page', function () { + let webview; + + function run_loop() { + webview.connect('load-changed', (webview, event) => { + if (event === WebKit2.LoadEvent.FINISHED) { + webhelper.translate_html(webview, null, (obj, res) => { + webhelper.translate_html_finish(res); + Mainloop.quit('webhelper2'); + }); + } + }); + webview.load_html('<html><body><p name="translatable">Translate Me</p></body></html>', + null); + Mainloop.run('webhelper2'); + } + + beforeEach(function () { + webview = new WebKit2.WebView(); + }); + + it('translates a string', function () { + let gettext_spy = jasmine.createSpy('gettext_spy').and.callFake((s) => s); + webhelper.set_gettext(gettext_spy); + run_loop(); + expect(gettext_spy).toHaveBeenCalledWith('Translate Me'); + }); + + // The following test is disabled because GJS cannot catch exceptions + // across FFI interfaces (e.g. in GObject callbacks.) + xit('complains about a gettext function not being set', function () { + expect(function () { + run_loop(); + }).toThrow(); + }); + + it('can cancel the translation operation', function (done) { + webhelper.set_gettext((s) => s); + webview.connect('load-changed', (webview, event) => { + if (event === WebKit2.LoadEvent.FINISHED) { + let cancellable = new Gio.Cancellable(); + cancellable.cancel(); + webhelper.translate_html(webview, cancellable, (obj, res) => { + expect(function () { + webhelper.translate_html_finish(res); + }).toThrow(); + done(); + }); + } + }); + webview.load_html('<html><body></body></html>', null); + }); + }); + + describe('used from client-side Javascript', function () { + let webview; + + beforeEach(function () { + webview = new WebKit2.WebView(); + }); + + function load_script(view, script) { + view.load_html('<html><body><script type="text/javascript">' + + script + '</script></body></html>', null); + Mainloop.run('webhelper2'); + } + + it('translates a string with gettext()', function (done) { + let gettext_spy = jasmine.createSpy('gettext_spy').and.callFake((s) => { + Mainloop.quit('webhelper2'); + return s; + }); + webhelper.set_gettext(gettext_spy); + load_script(webview, 'gettext("Translate Me");'); + expect(gettext_spy).toHaveBeenCalledWith('Translate Me'); + done(); + }); + + it('translates a string with ngettext()', function (done) { + let ngettext_spy = jasmine.createSpy('ngettext_spy').and.callFake((s, p, n) => { + Mainloop.quit('webhelper2'); + return n == 1 ? s : p; + }); + webhelper.set_ngettext(ngettext_spy); + load_script(webview, 'ngettext("File", "Files", 3);'); + expect(ngettext_spy).toHaveBeenCalledWith('File', 'Files', 3); + done(); + }); + }); +}); 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/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%'; |