1/*
2 Copyright (C) 2018 Christian Dywan <christian@twotoats.de>
3
4 This library is free software; you can redistribute it and/or
5 modify it under the terms of the GNU Lesser General Public
6 License as published by the Free Software Foundation; either
7 version 2.1 of the License, or (at your option) any later version.
8
9 See the file COPYING for the full license text.
10*/
11
12namespace Midori {
13    public interface AppActivatable : Peas.ExtensionBase {
14        public abstract App app { owned get; set; }
15        public abstract void activate ();
16    }
17
18    public class App : Gtk.Application {
19        public File? exec_path { get; protected set; default = null; }
20
21        static string? app = null;
22        [CCode (array_length = false, array_null_terminated = true)]
23        static string[]? execute = null;
24        static bool help_execute = false;
25        static int inactivity_reset = 0;
26        static bool incognito = false;
27        static bool version = false;
28        const OptionEntry[] options = {
29            { "app", 'a', 0, OptionArg.STRING, ref app, N_("Run ADDRESS as a web application"), N_("ADDRESS") },
30            { "execute", 'e', 0, OptionArg.STRING_ARRAY, ref execute, N_("Execute the specified command"), null },
31            { "help-execute", 0, 0, OptionArg.NONE, ref help_execute, N_("List available commands to execute with -e/ --execute"), null },
32            { "inactivity-reset", 'i', 0, OptionArg.INT, ref inactivity_reset, N_("Reset Midori after SECONDS seconds of inactivity"), N_("SECONDS") },
33            { "private", 'p', 0, OptionArg.NONE, ref incognito, N_("Private browsing, no changes are saved"), null },
34            { "version", 'V', 0, OptionArg.NONE, ref version, N_("Display version number"), null },
35            { null }
36        };
37        const ActionEntry[] actions = {
38            { "win-incognito-new", win_incognito_new_activated },
39            { "quit", quit_activated },
40        };
41
42        public App () {
43            Object (application_id: Config.PROJECT_DOMAIN,
44                    flags: ApplicationFlags.HANDLES_OPEN
45                         | ApplicationFlags.HANDLES_COMMAND_LINE);
46
47            add_main_option_entries (options);
48        }
49
50        public override bool local_command_line (ref weak string[] args, out int exit_status) {
51            exit_status = -1;
52            // Get the executable path
53            string executable = args[0];
54            try {
55                if (!Path.is_absolute (executable)) {
56                    executable = Environment.find_program_in_path (executable);
57                    if (FileUtils.test (executable, FileTest.IS_SYMLINK))
58                        executable = FileUtils.read_link (executable);
59                }
60            } catch (FileError error) {
61                debug ("Failed to look up exec path: %s", error.message);
62            }
63            exec_path = File.new_for_path (executable);
64
65            return base.local_command_line (ref args, out exit_status);
66        }
67
68        public override void startup () {
69            Intl.bindtextdomain (Config.PROJECT_NAME, null);
70            Intl.bind_textdomain_codeset (Config.PROJECT_NAME, "UTF-8");
71            Intl.textdomain (Config.PROJECT_NAME);
72
73            base.startup ();
74
75            Gtk.Window.set_default_icon_name (Config.PROJECT_DOMAIN);
76
77            var context = WebKit.WebContext.get_default ();
78            context.register_uri_scheme ("internal", (request) => {
79                request.ref ();
80                internal_scheme.begin (request);
81            });
82            context.register_uri_scheme ("favicon", (request) => {
83                request.ref ();
84                favicon_scheme.begin (request);
85            });
86            context.register_uri_scheme ("stock", (request) => {
87                request.ref ();
88                stock_scheme.begin (request);
89            });
90            context.register_uri_scheme ("res", (request) => {
91                try {
92                    var stream = resources_open_stream (request.get_path (),
93                                                        ResourceLookupFlags.NONE);
94                    request.finish (stream, -1, null);
95                } catch (Error error) {
96                    request.finish_error (error);
97                    critical ("Failed to load resource %s: %s", request.get_uri (), error.message);
98                }
99            });
100            string config = Path.build_path (Path.DIR_SEPARATOR_S,
101                Environment.get_user_config_dir (), Environment.get_prgname ());
102            DirUtils.create_with_parents (config, 0700);
103            string cookies = Path.build_filename (config, "cookies");
104            context.get_cookie_manager ().set_persistent_storage (cookies, WebKit.CookiePersistentStorage.SQLITE);
105            string cache = Path.build_path (Path.DIR_SEPARATOR_S,
106                Environment.get_user_cache_dir (), Environment.get_prgname ());
107            string icons = Path.build_path (Path.DIR_SEPARATOR_S, cache, "icondatabase");
108            context.set_favicon_database_directory (icons);
109            context.set_process_model (WebKit.ProcessModel.MULTIPLE_SECONDARY_PROCESSES);
110
111            // Try and load web extensions from build folder
112            var web_path = exec_path.get_parent ().get_child ("web");
113            if (!web_path.query_exists (null)) {
114                // Alternatively look for an installed path
115                web_path = File.new_for_path (Config.PLUGINDIR);
116            }
117            context.set_web_extensions_directory (web_path.get_path ());
118            context.initialize_web_extensions.connect (() => {
119                // Prefer plugins from the build folder
120                var builtin_path = exec_path.get_parent ().get_child ("extensions");
121                if (!builtin_path.query_exists (null)) {
122                    // System-wide plugins
123                    builtin_path = File.new_for_path (Config.PLUGINDIR);
124                }
125                context.set_web_extensions_initialization_user_data (builtin_path.get_path ());
126            });
127            var settings = CoreSettings.get_default ();
128            context.set_spell_checking_enabled (settings.enable_spell_checking);
129            settings.notify["enable-spell-checking"].connect ((pspec) => {
130                context.set_spell_checking_enabled (settings.enable_spell_checking);
131            });
132            context.get_cookie_manager ().set_accept_policy (
133                settings.first_party_cookies_only ? WebKit.CookieAcceptPolicy.NO_THIRD_PARTY : WebKit.CookieAcceptPolicy.ALWAYS);
134            settings.notify["first-party-cookies-only"].connect ((pspec) => {
135                context.get_cookie_manager ().set_accept_policy (
136                    settings.first_party_cookies_only ? WebKit.CookieAcceptPolicy.NO_THIRD_PARTY : WebKit.CookieAcceptPolicy.ALWAYS);
137            });
138            apply_proxy_settings (settings, context);
139            settings.notify["proxy-type"].connect ((pspec) => {
140                apply_proxy_settings (settings, context);
141            });
142            settings.notify["http-proxy"].connect ((pspec) => {
143                apply_proxy_settings (settings, context);
144            });
145            settings.notify["proxy-port"].connect ((pspec) => {
146                apply_proxy_settings (settings, context);
147            });
148
149            add_action_entries (actions, this);
150
151            var action = new SimpleAction ("win-new", VariantType.STRING);
152            action.activate.connect (win_new_activated);
153            add_action (action);
154            set_accels_for_action ("app.win-new", { "<Primary>n" });
155            set_accels_for_action ("app.win-incognito-new", { "<Primary><Shift>p", "<Primary><Shift>n" });
156
157            // Unset app menu if not handled by the shell
158            if (!Gtk.Settings.get_default ().gtk_shell_shows_app_menu){
159                app_menu = null;
160            }
161
162            // Try and load plugins from build folder
163            var builtin_path = exec_path.get_parent ().get_child ("extensions");
164            if (!builtin_path.query_exists (null)) {
165                // System-wide plugins
166                builtin_path = File.new_for_path (Config.PLUGINDIR);
167            }
168            var plugins = Plugins.get_default (builtin_path.get_path ());
169            // Save/ load state of plugins
170            plugins.load_plugin.connect ((info) => {
171                settings.set_plugin_enabled ("lib%s.so".printf (info.get_module_name ()), true);
172            });
173            plugins.unload_plugin.connect ((info) => {
174                settings.set_plugin_enabled ("lib%s.so".printf (info.get_module_name ()), false);
175            });
176
177            var extensions = Plugins.get_default ().plug<AppActivatable> ("app", this);
178            extensions.extension_added.connect ((info, extension) => ((AppActivatable)extension).activate ());
179            extensions.foreach ((extensions, info, extension) => { extensions.extension_added (info, extension); });
180        }
181
182        async void internal_scheme (WebKit.URISchemeRequest request) {
183            try {
184                var database = HistoryDatabase.get_default ();
185                var shortcuts = yield database.query (null, 9);
186                string content = "";
187                uint index = 0;
188                foreach (var shortcut in shortcuts) {
189                    var statement = database.prepare ("SELECT image FROM %s WHERE uri = :uri LIMIT 1".printf (database.table),
190                                                      ":uri", typeof (string), shortcut.uri);
191                    statement.step ();
192                    var image_uri = statement.get_string ("image") ?? "favicon:///" + shortcut.uri;
193                    index++;
194                    content += """
195                        <div class="shortcut" style="background-image: url('%s')">
196                          <a href="%s" accesskey="%u">
197                            <span class="title">%s</span>
198                          </a>
199                        </div>""".printf (image_uri, shortcut.uri, index, shortcut.title);
200                }
201                string stylesheet = (string)resources_lookup_data ("/data/about.css",
202                                                                    ResourceLookupFlags.NONE).get_data ();
203                string html = ((string)resources_lookup_data ("/data/speed-dial.html",
204                                                             ResourceLookupFlags.NONE).get_data ())
205                    .replace ("{title}", _("Speed Dial"))
206                    .replace ("{icon}", "view-grid")
207                    .replace ("{content}", content)
208                    .replace ("{stylesheet}", stylesheet);
209                var stream = new MemoryInputStream.from_data (html.data, free);
210                request.finish (stream, html.length, "text/html");
211            } catch (Error error) {
212                request.finish_error (error);
213                critical ("Failed to render %s: %s", request.get_uri (), error.message);
214            }
215            request.unref ();
216        }
217
218        void request_finish_pixbuf (WebKit.URISchemeRequest request, Gdk.Pixbuf pixbuf) throws Error {
219            var output = new MemoryOutputStream (null, realloc, free);
220            pixbuf.save_to_stream (output, "png");
221            output.close ();
222            uint8[] data = output.steal_data ();
223            data.length = (int)output.get_data_size ();
224            var stream = new MemoryInputStream.from_data (data, free);
225            request.finish (stream, -1, null);
226        }
227
228        async void favicon_scheme (WebKit.URISchemeRequest request) {
229            string page_uri = request.get_path ().substring (1, -1);
230            try {
231                var database = request.get_web_view ().web_context.get_favicon_database ();
232                var surface = yield database.get_favicon (page_uri, null);
233                if (surface != null) {
234                    var image = (Cairo.ImageSurface)surface;
235                    var icon = Gdk.pixbuf_get_from_surface (image, 0, 0, image.get_width (), image.get_height ());
236                    request_finish_pixbuf (request, icon);
237                }
238            } catch (Error error) {
239                request.finish_error (error);
240                debug ("Failed to render favicon for %s: %s", page_uri, error.message);
241            }
242            request.unref ();
243        }
244
245        async void stock_scheme (WebKit.URISchemeRequest request) {
246            string icon_name = request.get_path ().substring (1, -1);
247            int icon_size = 48;
248            Gtk.icon_size_lookup ((Gtk.IconSize)Gtk.IconSize.DIALOG, out icon_size, null);
249            try {
250                var icon = Gtk.IconTheme.get_default ().load_icon (icon_name, icon_size, Gtk.IconLookupFlags.FORCE_SYMBOLIC);
251                request_finish_pixbuf (request, icon);
252            } catch (Error error) {
253                request.finish_error (error);
254                critical ("Failed to load icon %s: %s", icon_name, error.message);
255            }
256            request.unref ();
257        }
258
259        void apply_proxy_settings (CoreSettings settings, WebKit.WebContext context) {
260            switch (settings.proxy_type) {
261                case ProxyType.AUTOMATIC:
262                    context.set_network_proxy_settings (WebKit.NetworkProxyMode.DEFAULT, null);
263                    break;
264                case ProxyType.HTTP:
265                    string proxy_uri = "%s:%d".printf (settings.http_proxy, settings.http_proxy_port);
266                    context.set_network_proxy_settings (
267                        WebKit.NetworkProxyMode.CUSTOM,
268                        new WebKit.NetworkProxySettings (proxy_uri, null));
269                    break;
270                case ProxyType.NONE:
271                    context.set_network_proxy_settings (WebKit.NetworkProxyMode.NO_PROXY, null);
272                    break;
273            }
274        }
275
276        internal WebKit.WebContext ephemeral_context () {
277            var context = new WebKit.WebContext.ephemeral ();
278            context.register_uri_scheme ("internal", (request) => {
279                request.ref ();
280                private_scheme.begin (request);
281            });
282            context.register_uri_scheme ("stock", (request) => {
283                request.ref ();
284                stock_scheme.begin (request);
285            });
286            context.register_uri_scheme ("res", (request) => {
287                try {
288                    var stream = resources_open_stream (request.get_path (),
289                                                        ResourceLookupFlags.NONE);
290                    request.finish (stream, -1, null);
291                } catch (Error error) {
292                    request.finish_error (error);
293                    critical ("Failed to load resource %s: %s", request.get_uri (), error.message);
294                }
295            });
296            var settings = CoreSettings.get_default ();
297            context.set_spell_checking_enabled (settings.enable_spell_checking);
298            settings.notify["enable-spell-checking"].connect ((pspec) => {
299                context.set_spell_checking_enabled (settings.enable_spell_checking);
300            });
301            // Enable the database by resetting the directory to the default
302            context.set_favicon_database_directory (null);
303            context.get_cookie_manager ().set_accept_policy (
304                settings.first_party_cookies_only ? WebKit.CookieAcceptPolicy.NO_THIRD_PARTY : WebKit.CookieAcceptPolicy.ALWAYS);
305            settings.notify["first-party-cookies-only"].connect ((pspec) => {
306                context.get_cookie_manager ().set_accept_policy (
307                    settings.first_party_cookies_only ? WebKit.CookieAcceptPolicy.NO_THIRD_PARTY : WebKit.CookieAcceptPolicy.ALWAYS);
308            });
309            apply_proxy_settings (settings, context);
310            settings.notify["proxy-type"].connect ((pspec) => {
311                apply_proxy_settings (settings, context);
312            });
313            settings.notify["http-proxy"].connect ((pspec) => {
314                apply_proxy_settings (settings, context);
315            });
316            settings.notify["proxy-port"].connect ((pspec) => {
317                apply_proxy_settings (settings, context);
318            });
319            return context;
320        }
321
322        async void private_scheme (WebKit.URISchemeRequest request) {
323            string[] suggestions = {
324                _("No history or web cookies are being saved."),
325                _("HTML5 storage, local database and application caches are disabled."),
326            };
327            string[] notes = {
328                _("DNS prefetching is disabled."),
329            };
330
331            try {
332                string description = "<ul>";
333                foreach (var suggestion in suggestions) {
334                    description += "<li>%s</li>".printf (suggestion);
335                }
336                description += "</ul>";
337                description += "<b>%s</b><br>".printf (_("Midori prevents websites from tracking the user:"));
338                description += "<ul>";
339                foreach (var note in notes) {
340                    description += "<li>%s</li>".printf (note);
341                }
342                description += "</ul>";
343                string stylesheet = (string)resources_lookup_data ("/data/about.css",
344                                                                    ResourceLookupFlags.NONE).get_data ();
345                string html = ((string)resources_lookup_data ("/data/error.html",
346                                                             ResourceLookupFlags.NONE).get_data ())
347                    .replace ("{title}", _("Private Browsing"))
348                    .replace ("{icon}", "user-not-tracked")
349                    .replace ("{message}", _("Midori doesn't store any personal data:"))
350                    .replace ("{description}", description)
351                    .replace ("{tryagain}", "")
352                    .replace ("{stylesheet}", stylesheet);
353                var stream = new MemoryInputStream.from_data (html.data, free);
354                request.finish (stream, html.length, "text/html");
355            } catch (Error error) {
356                request.finish_error (error);
357                critical ("Failed to render %s: %s", request.get_uri (), error.message);
358            }
359            request.unref ();
360        }
361
362        void win_new_activated (Action action, Variant? parameter) {
363            var browser = new Browser (this);
364            if (!browser.default_tab ()) {
365                browser.add (new Tab (null, browser.web_context));
366            }
367            string? uri = parameter.get_string () != "" ? parameter.get_string () : null;
368            if (uri != null) {
369                browser.add (new Tab (null, browser.web_context, uri));
370            }
371            browser.show ();
372        }
373
374        void win_incognito_new_activated () {
375            var browser = new Browser.incognito (this);
376            if (!browser.default_tab ()) {
377                browser.add (new Tab (null, browser.web_context));
378            }
379            browser.show ();
380        }
381
382        void quit_activated () {
383            quit ();
384        }
385
386        protected override void activate () {
387            if (incognito) {
388                activate_action ("win-incognito-new", null);
389                return;
390            }
391            activate_action ("win-new", "");
392        }
393
394        protected override void open (File[] files, string hint) {
395            var browser = incognito
396                ? new Browser.incognito (this)
397                : (active_window as Browser ?? new Browser (this));
398            foreach (File file in files) {
399                browser.add (new Tab (browser.tab, browser.web_context, file.get_uri ()));
400            }
401            browser.show ();
402        }
403
404        protected override int handle_local_options (VariantDict options) {
405            if (version) {
406                stdout.printf ("%s %s\n" +
407                               "Copyright 2007-2018 Christian Dywan\n" +
408                               "Please report comments, suggestions and bugs to:\n" +
409                               "    %s\n" +
410                               "Check for new versions at:\n" +
411                               "    %s\n ",
412                    Config.PROJECT_NAME, Config.CORE_VERSION,
413                    Config.PROJECT_BUGS, Config.PROJECT_WEBSITE);
414                return 0;
415            }
416
417            // Propagate options processed in the primary instance
418            options.insert_value ("app", app ?? "");
419            options.insert_value ("execute", execute);
420            options.insert_value ("help-execute", help_execute);
421            options.insert_value ("inactivity-reset", inactivity_reset);
422            options.insert_value ("private", incognito);
423            return -1;
424        }
425
426        protected override int command_line (ApplicationCommandLine command_line) {
427            hold ();
428
429            // Retrieve values for options passed from another process
430            var options = command_line.get_options_dict ();
431            app = options.lookup_value ("app", VariantType.STRING).get_string ();
432            execute = options.lookup_value ("execute", VariantType.STRING_ARRAY).dup_strv ();
433            help_execute = options.lookup_value ("help-execute", VariantType.BOOLEAN).get_boolean ();
434            inactivity_reset = options.lookup_value ("inactivity-reset", VariantType.INT32).get_int32 ();
435            incognito = options.lookup_value ("private", VariantType.BOOLEAN).get_boolean ();
436            debug ("Processing remote command line %s/ %s\n",
437                   string.joinv (", ", command_line.get_arguments ()), options.end ().print (true));
438
439            if (help_execute) {
440                foreach (string action in list_actions ()) {
441                    command_line.print ("%s\n", action);
442                }
443                var browser = incognito ? new Browser.incognito (this) : new Browser (this);
444                foreach (string action in browser.list_actions ()) {
445                    command_line.print ("%s\n", action);
446                }
447            }
448
449            if (app != "") {
450                var browser = new Browser (this, true);
451                var tab = new Tab (null, browser.web_context, app);
452                tab.pinned = true;
453                browser.add (tab);
454                browser.show ();
455                if (inactivity_reset > 0) {
456                    Timeout.add_seconds (inactivity_reset, () => {
457                        if (browser.idle) {
458                            tab.load_uri (app);
459                        } else {
460                            browser.idle = true;
461                        }
462                        return Source.CONTINUE;
463                    }, Priority.LOW);
464                }
465            }
466
467            uint argc = command_line.get_arguments ().length;
468            if (argc <= 1) {
469                if (active_window == null) {
470                    activate ();
471                }
472            } else {
473                var files = new File[argc - 1];
474                uint i = 0;
475                foreach (string argument in command_line.get_arguments ()) {
476                    // Skip program name
477                    if (i > 0) {
478                        files[i - 1] = File.new_for_commandline_arg (argument);
479                    }
480                    i++;
481                }
482                open (files, "");
483            }
484
485            var action_group = active_window as ActionGroup;
486            foreach (string action_ in execute) {
487                // Accept action names regardless of case
488                string action = action_.down ();
489                debug ("Executing %s\n", action);
490                if (action_group.has_action (action)) {
491                    action_group.activate_action (action, null);
492                } else {
493                    warning (_("Unexpected action '%s'.").printf (action));
494                }
495            }
496
497            release ();
498            return  0;
499        }
500    }
501}
502