summaryrefslogtreecommitdiff
path: root/extensions/web-extensions.vala
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/web-extensions.vala')
-rw-r--r--extensions/web-extensions.vala372
1 files changed, 372 insertions, 0 deletions
diff --git a/extensions/web-extensions.vala b/extensions/web-extensions.vala
new file mode 100644
index 00000000..155248d6
--- /dev/null
+++ b/extensions/web-extensions.vala
@@ -0,0 +1,372 @@
+/*
+ Copyright (C) 2019 Christian Dywan <christian@twotoats.de>
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ See the file COPYING for the full license text.
+*/
+
+namespace WebExtension {
+ public class Extension : Object {
+ public File file { get; protected set; }
+ public string name { get; set; }
+ public string description { get; set; }
+ public string? background_page { get; owned set; }
+ public List<string> background_scripts { get; owned set; }
+ public List<string> content_scripts { get; owned set; }
+ public List<string> content_styles { get; owned set; }
+ public Action? browser_action { get; set; }
+
+ public Extension (File file) {
+ Object (file: file, name: file.get_basename ());
+ }
+ }
+
+ public class Action : Object {
+ public string? icon { get; protected set; }
+ public string? title { get; protected set; }
+ public string? popup { get; protected set; }
+
+ public Action (string? icon, string? title, string? popup) {
+ Object (icon: icon, title: title, popup: popup);
+ }
+ }
+
+ public class ExtensionManager : Object {
+ static ExtensionManager? _default = null;
+ HashTable<string, Extension> extensions;
+
+ public delegate void ExtensionManagerForeachFunc (Extension extension);
+ public void @foreach (ExtensionManagerForeachFunc func) {
+ extensions.foreach ((key, value) => {
+ func (value);
+ });
+ }
+
+ // Note: Can't use the actual type here
+ // parameter 1 of type 'WebExtensionExtension' for signal
+ // "WebExtensionExtensionManager::extension_added" is not a value type
+ public signal void extension_added (Object extension);
+
+ public static ExtensionManager get_default () {
+ if (_default == null) {
+ _default = new ExtensionManager ();
+ _default.extensions = new HashTable<string, Extension> (str_hash, str_equal);
+ }
+ return _default;
+ }
+
+ public async void load_from_folder (WebKit.UserContentManager content, File folder) throws Error {
+ debug ("Load web extensions from %s", folder.get_path ());
+ var enumerator = yield folder.enumerate_children_async (FileAttribute.STANDARD_NAME, 0);
+ FileInfo info;
+ while ((info = enumerator.next_file ()) != null) {
+ var file = folder.get_child (info.get_name ());
+ string id = Checksum.compute_for_string (ChecksumType.MD5, file.get_path ());
+ var extension = extensions.lookup (id);
+ if (extension == null) {
+ extension = new Extension (file);
+
+ // If we find a manifest, this is a web extension
+ var manifest_file = file.get_child ("manifest.json");
+ if (!manifest_file.query_exists ()) {
+ continue;
+ }
+
+ try {
+ var json = new Json.Parser ();
+ yield json.load_from_stream_async (new DataInputStream (manifest_file.read ()));
+ var manifest = json.get_root ().get_object ();
+ if (manifest.has_member ("name")) {
+ extension.name = manifest.get_string_member ("name");
+ }
+
+ if (manifest.has_member ("background")) {
+ var background = manifest.get_object_member ("background");
+ if (background != null) {
+ if (background.has_member ("page")) {
+ extension.background_page = background.get_string_member ("page");
+ }
+
+ if (background.has_member ("scripts")) {
+ foreach (var element in background.get_array_member ("scripts").get_elements ()) {
+ extension.background_scripts.append (element.get_string ());
+ }
+ }
+ }
+ }
+
+ if (manifest.has_member ("browser_action")) {
+ var action = manifest.get_object_member ("browser_action");
+ if (action != null) {
+ extension.browser_action = new Action (
+ action.has_member ("default_icon") ? action.get_string_member ("default_icon") : null,
+ action.has_member ("default_title") ? action.get_string_member ("default_title") : null,
+ action.has_member ("default_popup") ? action.get_string_member ("default_popup") : null);
+ }
+ }
+
+ if (manifest.has_member ("content_scripts")) {
+ var content_scripts = manifest.get_object_member ("content_scripts");
+ if (content_scripts != null && content_scripts.has_member ("js")) {
+ foreach (var element in content_scripts.get_array_member ("js").get_elements ()) {
+ extension.content_scripts.append (element.get_string ());
+ }
+ }
+
+ if (content_scripts != null && content_scripts.has_member ("css")) {
+ foreach (var element in content_scripts.get_array_member ("css").get_elements ()) {
+ extension.content_styles.append (element.get_string ());
+ }
+ }
+ }
+
+ debug ("Loaded %s from %s", extension.name, file.get_path ());
+ extensions.insert (id, extension);
+ extension_added (extension);
+ } catch (Error error) {
+ warning ("Failed to load extension '%s': %s\n", extension.name, error.message);
+ }
+ }
+
+ foreach (var filename in extension.content_scripts) {
+ uint8[] script;
+ yield file.get_child (filename).load_contents_async (null, out script, null);
+ content.add_script (new WebKit.UserScript ((string)script,
+ WebKit.UserContentInjectedFrames.TOP_FRAME,
+ WebKit.UserScriptInjectionTime.END,
+ null, null));
+ }
+ foreach (var filename in extension.content_styles) {
+ uint8[] stylesheet;
+ yield file.get_child (filename).load_contents_async (null, out stylesheet, null);
+ content.add_style_sheet (new WebKit.UserStyleSheet ((string)stylesheet,
+ WebKit.UserContentInjectedFrames.TOP_FRAME,
+ WebKit.UserStyleLevel.USER,
+ null, null));
+ }
+ }
+ }
+
+ Midori.App app { get { return Application.get_default () as Midori.App; } }
+ Midori.Browser browser { get { return app.active_window as Midori.Browser; } }
+
+ void web_extension_message_received (WebKit.WebView web_view, WebKit.JavascriptResult result) {
+ unowned JS.GlobalContext context = result.get_global_context ();
+ unowned JS.Value value = result.get_value ();
+ if (value.is_object (context)) {
+ var object = value.to_object (context);
+ string? fn = js_to_string (context, object.get_property (context, new JS.String.create_with_utf8_cstring ("fn")));
+ if (fn != null && fn.has_prefix ("tabs.create")) {
+ var args = object.get_property (context, new JS.String.create_with_utf8_cstring ("args")).to_object (context);
+ string? url = js_to_string (context, args.get_property (context, new JS.String.create_with_utf8_cstring ("url")));
+ var tab = new Midori.Tab (null, browser.tab.web_context, url);
+ browser.add (tab);
+ var promise = object.get_property (context, new JS.String.create_with_utf8_cstring ("promise")).to_number (context);
+ debug ("Calling back to promise #%.f".printf (promise));
+ web_view.run_javascript.begin ("promises[%.f].resolve({id:%s});".printf (promise, tab.id));
+ } else if (fn != null && fn.has_prefix ("tabs.executeScript")) {
+ var args = object.get_property (context, new JS.String.create_with_utf8_cstring ("args")).to_object (context);
+ string? results = null;
+ string? code = js_to_string (context, args.get_property (context, new JS.String.create_with_utf8_cstring ("code")));
+ if (code != null) {
+ results = "[true]";
+ browser.tab.run_javascript.begin (code);
+ }
+ var promise = object.get_property (context, new JS.String.create_with_utf8_cstring ("promise")).to_number (context);
+ debug ("Calling back to promise #%.f".printf (promise));
+ web_view.run_javascript.begin ("promises[%.f].resolve(%s);".printf (promise, results ?? "[undefined]"));
+ } else if (fn != null && fn.has_prefix ("notifications.create")) {
+ var args = object.get_property (context, new JS.String.create_with_utf8_cstring ("args")).to_object (context);
+ string? message = js_to_string (context, args.get_property (context, new JS.String.create_with_utf8_cstring ("message")));
+ string? title = js_to_string (context, args.get_property (context, new JS.String.create_with_utf8_cstring ("title")));
+ var notification = new Notification (title);
+ notification.set_body (message);
+ // Use per-extension ID to avoid collisions
+ string extension_uri = web_view.uri;
+ app.send_notification (extension_uri, notification);
+ } else {
+ warning ("Unsupported Web Extension API: %s", fn);
+ }
+ } else {
+ warning ("Unexpected non-object value posted to Web Extension API: %s", js_to_string (context, value));
+ }
+ }
+
+ public void install_api (WebKit.WebView web_view) {
+ web_view.get_settings ().enable_write_console_messages_to_stdout = true; // XXX
+
+ var content = web_view.get_user_content_manager ();
+ if (content.register_script_message_handler ("midori")) {
+ content.script_message_received.connect ((result) => {
+ web_extension_message_received (web_view, result);
+ });
+ try {
+ string script = (string)resources_lookup_data ("/data/web-extension-api.js",
+ ResourceLookupFlags.NONE).get_data ();
+ content.add_script (new WebKit.UserScript ((string)script,
+ WebKit.UserContentInjectedFrames.ALL_FRAMES,
+ WebKit.UserScriptInjectionTime.START,
+ null, null));
+
+ } catch (Error error) {
+ critical ("Failed to setup WebExtension API: %s", error.message);
+ }
+ } else {
+ warning ("Failed to install WebExtension API handler");
+ }
+
+ }
+ }
+
+ public class WebView : WebKit.WebView {
+ public WebView (Extension extension, string? uri = null) {
+ Object (visible: true);
+
+ var manager = ExtensionManager.get_default ();
+ manager.install_api (this);
+
+ if (uri != null) {
+ load_uri (extension.file.get_child (uri).get_uri ());
+ } else {
+ load_html ("<body></body>", extension.file.get_uri ());
+ }
+ }
+
+ public override bool context_menu (WebKit.ContextMenu menu,
+ Gdk.Event event, WebKit.HitTestResult hit) {
+
+ if (hit.context_is_editable ()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public override void close () {
+ destroy ();
+ }
+
+ public override bool web_process_crashed () {
+ load_alternate_html ("<body><button onclick='location.reload();'>Reload</button></body>", uri, uri);
+ return true;
+ }
+ }
+
+ public class Button : Gtk.MenuButton {
+ public Button (Extension extension) {
+ tooltip_text = extension.browser_action.title ?? extension.name;
+ visible = true;
+ focus_on_click = false;
+ var icon = new Gtk.Image.from_icon_name ("midori-symbolic", Gtk.IconSize.BUTTON);
+ icon.use_fallback = true;
+ icon.visible = true;
+ if (extension.browser_action.icon != null) {
+ debug ("Icon for %s: %s\n",
+ extension.name,
+ extension.file.get_child (extension.browser_action.icon).get_path ());
+ // Ensure the icon fits the size of a button in the toolbar
+ int icon_width = 16, icon_height = 16;
+ Gtk.icon_size_lookup (Gtk.IconSize.BUTTON, out icon_width, out icon_height);
+ // Take scale factor into account
+ icon_width *= scale_factor;
+ icon_height *= scale_factor;
+ try {
+ string filename = extension.file.get_child (extension.browser_action.icon).get_path ();
+ icon.pixbuf = new Gdk.Pixbuf.from_file_at_scale (filename, icon_width, icon_height, true);
+ } catch (Error error) {
+ warning ("Failed to set icon for %s: %s", extension.name, error.message);
+ }
+ }
+ if (extension.browser_action.popup != null) {
+ popover = new Gtk.Popover (this);
+ popover.add (new WebView (extension, extension.browser_action.popup));
+ }
+ add (icon);
+ }
+ }
+
+ static string? js_to_string (JS.GlobalContext context, JS.Value value) {
+ if (!value.is_string (context)) {
+ return null;
+ }
+ var str = value.to_string_copy (context);
+ uint8[] buffer = new uint8[str.get_maximum_utf8_cstring_size ()];
+ str.get_utf8_cstring (buffer);
+ return ((string)buffer);
+ }
+
+ public class Browser : Object, Midori.BrowserActivatable {
+ public Midori.Browser browser { owned get; set; }
+
+ async void install_extension (Extension extension) throws Error {
+ if (extension.browser_action != null) {
+ browser.add_button (new Button (extension as Extension));
+ }
+
+ // Employ a delay to avoid delaying startup with many extensions
+ uint src = Timeout.add (500, install_extension.callback);
+ yield;
+ Source.remove (src);
+
+ // Insert the background page in the browser, as a hidden widget
+ var background = new WebView (extension, extension.background_page);
+ (((Gtk.Container)browser.get_child ())).add (background);
+
+ foreach (var filename in extension.background_scripts) {
+ uint8[] script;
+ yield extension.file.get_child (filename).load_contents_async (null, out script, null);
+ background.get_user_content_manager ().add_script (new WebKit.UserScript ((string)script,
+ WebKit.UserContentInjectedFrames.TOP_FRAME,
+ WebKit.UserScriptInjectionTime.END,
+ null, null));
+ }
+ }
+
+ public void activate () {
+ if (browser.is_locked) {
+ return;
+ }
+
+ var manager = ExtensionManager.get_default ();
+ manager.extension_added.connect ((extension) => {
+ install_extension.begin ((Extension)extension);
+ });
+ manager.foreach ((extension) => {
+ install_extension.begin ((Extension)extension);
+ });
+
+ browser.tabs.add.connect (tab_added);
+ if (browser.tab != null) {
+ tab_added (browser.tab);
+ }
+ }
+
+ void tab_added (Gtk.Widget widget) {
+ browser.tabs.add.disconnect (tab_added);
+
+ var manager = ExtensionManager.get_default ();
+ var tab = widget as Midori.Tab;
+
+ var content = tab.get_user_content_manager ();
+ // Try and load plugins from build folder
+ var builtin_path = ((Midori.App)Application.get_default ()).exec_path.get_parent ().get_child ("extensions");
+ manager.load_from_folder.begin (content, builtin_path);
+ // System-wide plugins
+ manager.load_from_folder.begin (content, File.new_for_path (Config.PLUGINDIR));
+ // Plugins installed by the user
+ string user_path = Path.build_path (Path.DIR_SEPARATOR_S,
+ Environment.get_user_data_dir (), Config.PROJECT_NAME, "extensions");
+ manager.load_from_folder.begin (content, File.new_for_path (user_path));
+ }
+ }
+}
+
+[ModuleInit]
+public void peas_register_types(TypeModule module) {
+ ((Peas.ObjectModule)module).register_extension_type (
+ typeof (Midori.BrowserActivatable), typeof (WebExtension.Browser));
+}