summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore18
-rw-r--r--Makefile.am76
-rw-r--r--configure.ac14
-rw-r--r--docs/reference/webhelper/eos.css2
-rw-r--r--endless/Makefile.am2
-rw-r--r--jasmine.json14
-rw-r--r--m4/eos-gir.m490
-rw-r--r--test/Makefile.am.inc3
-rw-r--r--test/smoke-tests/webhelper/webview2.js147
-rw-r--r--test/webhelper/testTranslate2.js178
-rw-r--r--test/webhelper/testWebActions2.js121
-rw-r--r--webhelper/Makefile.am.inc12
-rw-r--r--webhelper/lib/wh2private.c36
-rw-r--r--webhelper/lib/wh2private.h20
-rw-r--r--webhelper/webextensions/wh2extension.c524
-rw-r--r--webhelper/webextensions/wh2jscutil.c65
-rw-r--r--webhelper/webextensions/wh2jscutil.h25
-rw-r--r--webhelper/webhelper2.js500
-rw-r--r--webhelper/webhelper_private/config.js.in1
19 files changed, 1812 insertions, 36 deletions
diff --git a/.gitignore b/.gitignore
index 525f885..cba4d8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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%';