gui.py 75.5 KB
Newer Older
1
2
3
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4 nu

thiolliere's avatar
GUI    
thiolliere committed
4
import os
thiolliere's avatar
thiolliere committed
5
import sys
6
import json
rgaudin's avatar
rgaudin committed
7
import platform
8
import tempfile
thiolliere's avatar
thiolliere committed
9
import threading
10
import multiprocessing
11
12
13
14
from collections import OrderedDict

import gi
import pytz
15
import iso639
16
import tzlocal
rgaudin's avatar
rgaudin committed
17
import requests
18
import humanfriendly
19
import webbrowser
20
21
22
23
24
25
26
27
28
29
30
31


from backend.content import (
    get_expanded_size,
    get_collection,
    get_required_image_size,
    get_content,
    isremote,
)
import data
import sd_card_info
from util import relpathto
32
33
from util import get_cache
from util import CLILogger
34
35
36
37
38
from util import check_user_inputs
from version import get_version_str
from util import b64encode, b64decode
from util import get_free_space_in_dir
from util import get_adjusted_image_size
rgaudin's avatar
rgaudin committed
39
from util import split_proxy, save_prefs
40
from backend.catalog import get_catalogs
41
42
from util import CancelEvent, ProgressHelper
from run_installation import run_installation
43
from backend.util import sd_has_single_partition, flash_image_with_etcher
44
45
from backend.mount import open_explorer_for_imdisk
from util import human_readable_size, ONE_GB, ONE_MiB
46
47
from backend.cache import clean_cache, reset_cache
from backend.cache import get_cache_size_and_free_space
rgaudin's avatar
rgaudin committed
48
from backend.download import get_proxies, test_connection
49
50

gi.require_version("Gtk", "3.0")
rgaudin's avatar
rgaudin committed
51
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf, GObject
thiolliere's avatar
thiolliere committed
52

53
54
VALID_RGBA = Gdk.RGBA(0., 0., 0., 0.)
INVALID_RGBA = Gdk.RGBA(1, 0.5, 0.5, 1.)
55
56
mainloop = None

57

58
59
60
61
def quit(*args, **kwargs):
    global mainloop
    mainloop.quit()

62

63
64
65
66
67
68
69
70
def run():
    global mainloop
    mainloop = GObject.MainLoop()
    try:
        mainloop.run()
    except KeyboardInterrupt:
        print("\nKeyboardInterrupt, exiting.")
        quit()
thiolliere's avatar
thiolliere committed
71

72

73
74
class ShortDialog(Gtk.Dialog):
    def __init__(self, parent, buttons, msg):
75
        Gtk.Dialog.__init__(self, "Kiwix Hotspot", parent, 0, buttons)
76
77
78
79
80
81
        self.set_default_size(150, 100)
        label = Gtk.Label(msg)
        box = self.get_content_area()
        box.add(label)
        self.show_all()

82

83
84
85
86
def hide_on_delete(widget, event):
    widget.hide()
    return True

87

rgaudin's avatar
rgaudin committed
88
89
90
91
92
93
class Logger(ProgressHelper):
    def __init__(self, component):
        super(Logger, self).__init__()
        self.component = component

        self.text_buffer = self.component.run_text_view.get_buffer()
thiolliere's avatar
thiolliere committed
94
95
        self.step_tag = self.text_buffer.create_tag("step", foreground="blue")
        self.err_tag = self.text_buffer.create_tag("err", foreground="red")
96
97
        self.succ_tag = self.text_buffer.create_tag("succ", foreground="green")
        self.stg_tag = self.text_buffer.create_tag("stg", foreground="purple")
rgaudin's avatar
rgaudin committed
98
99
        self.run_pulse()

100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
    @property
    def on_tty(self):
        return False

    def flash(self, line):
        pass

    def ascii_progressbar(self, current, total):
        width = 60
        avail_dots = width - 2
        if total == -1:
            line = "unknown size"
        elif current >= total:
            line = "[" + "." * avail_dots + "] 100%\n"
        else:
            ratio = min(float(current) / total, 1.)
            shaded_dots = min(int(ratio * avail_dots), avail_dots)
            percent = min(int(ratio * 100), 100)
            line = (
                "["
                + "." * shaded_dots
                + " " * (avail_dots - shaded_dots)
                + "] "
                + str(percent)
                + "%\r"
            )

        if getattr(self, "_last_progress_line", None) != line:
            self.raw_std(line)
            setattr(self, "_last_progress_line", line)

thiolliere's avatar
thiolliere committed
131
    def step(self, step):
132
        GLib.idle_add(self.main_thread_step, step)
thiolliere's avatar
thiolliere committed
133

thiolliere's avatar
thiolliere committed
134
    def err(self, err):
135
        GLib.idle_add(self.main_thread_err, err)
thiolliere's avatar
thiolliere committed
136

rgaudin's avatar
rgaudin committed
137
138
139
    def succ(self, succ):
        GLib.idle_add(self.main_thread_succ, succ)

thiolliere's avatar
thiolliere committed
140
    def raw_std(self, std):
141
        GLib.idle_add(self.main_thread_raw_std, std, end="")
thiolliere's avatar
thiolliere committed
142

rgaudin's avatar
rgaudin committed
143
144
145
146
147
148
149
150
    def std(self, std, end=None):
        GLib.idle_add(self.main_thread_std, std, end)

    def complete(self):
        GLib.idle_add(self.main_thread_complete)

    def failed(self, error):
        GLib.idle_add(self.main_thread_failed, error)
151

152
    def main_thread_text(self, text, end="\n", tag=None):
153
154
        if not isinstance(text, str):
            text = str(text)
155
        text = self._add_time(text)
156
        text += end
thiolliere's avatar
thiolliere committed
157
        text_iter = self.text_buffer.get_end_iter()
158
159
160
161
162
163
164
        if tag is None:
            self.text_buffer.insert(text_iter, text)
        else:
            self.text_buffer.insert_with_tags(text_iter, text, tag)

    def main_thread_step(self, text):
        self.main_thread_text("--> {}".format(text), "\n", self.step_tag)
rgaudin's avatar
rgaudin committed
165
        self._update_progress_text(text)
166
167

    def main_thread_err(self, text):
168
        self.main_thread_text(text, "\n", self.err_tag)
169

rgaudin's avatar
rgaudin committed
170
171
172
    def main_thread_succ(self, text):
        self.main_thread_text(text, "\n", self.succ_tag)

173
    def main_thread_raw_std(self, text):
174
        self.main_thread_text(text)
175

rgaudin's avatar
rgaudin committed
176
    def main_thread_std(self, text, end=None):
177
        self.main_thread_text(text, end if end is not None else "\n")
rgaudin's avatar
rgaudin committed
178
179
180
181
182
183
184
185

    def _update_progress_text(self, text):
        self.component.run_progressbar.set_text(text)

    def update(self):
        GLib.idle_add(self.update_gui)

    def update_gui(self):
186
        # show text progress in console
187
188
189
190
191
192
193
194
        self.main_thread_text(
            "[STAGE {nums}: {name} - {pc:.0f}%]".format(
                nums=self.stage_numbers,
                name=self.stage_name,
                pc=self.get_overall_progress() * 100,
            ),
            tag=self.stg_tag,
        )
195

rgaudin's avatar
rgaudin committed
196
197
        # update overall percentage on window title
        self.component.run_window.set_title(
198
199
            "Kiwix Hotspot ({:.0f}%)".format(self.get_overall_progress() * 100)
        )
rgaudin's avatar
rgaudin committed
200
201
202

        # update stage name and number (Stage x/y)
        self.component.run_step_label.set_markup(
203
204
205
206
            "<b>Stage {nums}</b>: {name}".format(
                nums=self.stage_numbers, name=self.stage_name
            )
        )
rgaudin's avatar
rgaudin committed
207
208
209
210
211
212
213
214
215
216
217

        # update the progress bar according to the stage's progress
        if self.stage_progress is not None:
            self.component.run_progressbar.set_inverted(False)
            self.component.run_progressbar.set_fraction(self.stage_progress)
        else:
            # animate the stage progress bar to show an unknown progress
            self.run_pulse()

    def main_thread_complete(self):
        super(Logger, self).complete()
rgaudin's avatar
rgaudin committed
218
        self.main_thread_succ("Installation succeded.")
rgaudin's avatar
rgaudin committed
219
220
221
222
223
        self.component.run_step_label.set_markup("<b>Done.</b>")
        self.progress(1)

    def main_thread_failed(self, error):
        super(Logger, self).failed()
224
        self.step("Failed: {}".format(error[0:50]))
225
        self.err("Installation failed: {}".format(error))
rgaudin's avatar
rgaudin committed
226
227
228
        self.progress(1)

    def run_pulse(self):
229
        """ used for progress bar animation (unknown progress) """
rgaudin's avatar
rgaudin committed
230
231
232
233
        self._update_progress_text("")
        self.timeout_id = GObject.timeout_add(50, self.on_timeout)

    def on_timeout(self):
234
        """ used for progress bar animation (unknown progress) """
rgaudin's avatar
rgaudin committed
235
236
237
238
239
240
241
        if self.stage_progress is None:
            new_value = self.component.run_progressbar.get_fraction() + 0.035
            # inverse direction if end reached
            if new_value > 1:
                new_value = 0
                # switch from left-to-right to right-to-left at bounds
                self.component.run_progressbar.set_inverted(
242
243
                    not self.component.run_progressbar.get_inverted()
                )
rgaudin's avatar
rgaudin committed
244
245
            self.component.run_progressbar.set_fraction(new_value)
            return True  # returns True so it continues to get called
thiolliere's avatar
thiolliere committed
246

247

thiolliere's avatar
GUI    
thiolliere committed
248
249
250
251
252
253
254
255
256
257
258
259
class Component:
    def __init__(self, builder):
        self.builder = builder

    def __getattr__(self, key):
        """Allow UI builder widgets to be accessed as self.widgetname"""
        widget = self.builder.get_object(key)
        if widget:
            setattr(self, key, widget)
            return widget
        raise AttributeError(key)

260

thiolliere's avatar
GUI    
thiolliere committed
261
262
263
264
def validate_label(label, condition):
    if condition:
        label.modify_bg(Gtk.StateFlags.NORMAL)
    else:
265
        label.modify_bg(Gtk.StateFlags.NORMAL, INVALID_RGBA.to_color())
thiolliere's avatar
GUI    
thiolliere committed
266

267

thiolliere's avatar
thiolliere committed
268
class Application:
269
270
    def __init__(self):
        self.catalogs = None
thiolliere's avatar
GUI    
thiolliere committed
271
272

        builder = Gtk.Builder()
thiolliere's avatar
thiolliere committed
273
        builder.add_from_file(data.ui_glade)
thiolliere's avatar
GUI    
thiolliere committed
274
275

        self.component = Component(builder)
thiolliere's avatar
thiolliere committed
276
        self.cancel_event = CancelEvent()
rgaudin's avatar
rgaudin committed
277
        self.logger = Logger(self.component)
thiolliere's avatar
thiolliere committed
278

thiolliere's avatar
thiolliere committed
279
        # main window
280
        self.component.window.connect("delete-event", quit)
thiolliere's avatar
thiolliere committed
281

282
283
284
285
        # gtk file filters (macOS fix)
        self.component.favicon_filter.set_name("Favicon (ICO, PNG)")  # opt
        self.component.favicon_filter.add_pattern("*.png")
        self.component.favicon_filter.add_pattern("*.ico")
286
        self.component.favicon_chooser.add_filter(self.component.favicon_filter)
287
288
289

        self.component.logo_filter.set_name("Logo (PNG)")  # opt
        self.component.logo_filter.add_pattern("*.png")
290
        self.component.logo_chooser.add_filter(self.component.logo_filter)
291
292
293

        self.component.css_filter.set_name("CSS File")  # opt
        self.component.css_filter.add_pattern("*.css")
294
        self.component.css_chooser.add_filter(self.component.css_filter)
295

rgaudin's avatar
rgaudin committed
296
297
        self.component.edupi_resources_filter.set_name("ZIP File")  # opt
        self.component.edupi_resources_filter.add_pattern("*.zip")
298
        self.component.edupi_resources_chooser.add_filter(
299
300
            self.component.edupi_resources_filter
        )
rgaudin's avatar
rgaudin committed
301

thiolliere's avatar
thiolliere committed
302
        # menu bar
303
304
305
        self.component.menu_quit.connect(
            "activate", lambda widget: self.component.window.close()
        )
thiolliere's avatar
thiolliere committed
306
        self.component.menu_about.connect("activate", self.activate_menu_about)
thiolliere's avatar
thiolliere committed
307

308
        self.component.menu_load_config.connect(
309
310
            "activate", self.activate_menu_config, False
        )
311
        self.component.menu_save_config.connect(
312
313
314
            "activate", self.activate_menu_config, True
        )
        self.component.menu_help.connect("activate", self.activate_menu_help)
315

316
        # imdisk menu is windows only
317
        if sys.platform == "win32":
318
            self.component.menu_imdisk.set_visible(True)
319
            self.component.menu_imdisk.connect("activate", self.activate_menu_imdisk)
320

rgaudin's avatar
rgaudin committed
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
        # proxies
        self.component.menu_proxies.connect(
            "activate", lambda widget: self.component.proxies_dialog.show()
        )
        self.component.reset_proxies_button.connect(
            "clicked", self.reset_proxies_button_clicked
        )
        self.component.save_proxies_button.connect(
            "clicked", self.save_proxies_button_clicked
        )
        self.component.test_proxies_button.connect(
            "clicked", self.test_proxies_button_clicked
        )
        self.component.proxies_dialog.connect(
            "show", lambda widget: self._set_proxies_entries()
        )
        self.component.proxies_dialog.connect("delete-event", hide_on_delete)

rgaudin's avatar
rgaudin committed
339
        # etcher
340
        self.component.menu_etcher.connect("activate", self.activate_menu_etcher)
rgaudin's avatar
rgaudin committed
341

342
343
344
        # cache
        self.component.clean_cache_button.connect("clicked", self.activate_menu_cache)

345
346
347
        # sd clean
        self.component.clean_sd_button.connect("clicked", self.activate_sd_clean)

thiolliere's avatar
GUI    
thiolliere committed
348
        # wifi password
349
350
351
352
353
354
        self.component.wifi_password_switch.connect(
            "notify::active",
            lambda switch, state: self.component.wifi_password_revealer.set_reveal_child(
                not switch.get_active()
            ),
        )
thiolliere's avatar
GUI    
thiolliere committed
355

rgaudin's avatar
rgaudin committed
356
        # edupi resources
357
358
359
360
361
362
        self.component.edupi_switch.connect(
            "notify::active",
            lambda switch, state: self.component.edupi_resources_revealer.set_reveal_child(
                switch.get_active()
            ),
        )
rgaudin's avatar
rgaudin committed
363

thiolliere's avatar
thiolliere committed
364
        # ideascube language
rgaudin's avatar
rgaudin committed
365
        for code, language in data.hotspot_languages:
thiolliere's avatar
thiolliere committed
366
367
368
369
370
            self.component.language_tree_store.append([code, language])

        renderer = Gtk.CellRendererText()
        self.component.language_combobox.pack_start(renderer, True)
        self.component.language_combobox.add_attribute(renderer, "text", 1)
thiolliere's avatar
thiolliere committed
371

372
373
374
375
376
377
378
        # SD sizes for image size
        for ngb in data.sdcard_sizes:
            self.component.sizes_tree_store.append([str(ngb), "{} GB".format(ngb)])
        renderer = Gtk.CellRendererText()
        self.component.size_combobox.pack_start(renderer, True)
        self.component.size_combobox.add_attribute(renderer, "text", 1)

thiolliere's avatar
GUI    
thiolliere committed
379
380
381
382
        renderer = Gtk.CellRendererText()
        self.component.timezone_combobox.pack_start(renderer, True)
        self.component.timezone_combobox.add_attribute(renderer, "text", 0)

383
384
385
        # build-path
        self.component.build_path_chooser.connect("file-set", self.changed_build_path)

thiolliere's avatar
thiolliere committed
386
        # output
387
388
389
        self.component.sd_card_combobox.connect(
            "changed", lambda _: self.update_free_space()
        )
390
        self.component.sd_card_combobox.connect(
rgaudin's avatar
rgaudin committed
391
            "changed", lambda w: self.on_sdcard_selection_change(w)
392
        )
393
394
395
396
397
398
399
400
401
        self.component.sd_card_refresh_button.connect(
            "clicked", self.sd_card_refresh_button_clicked
        )
        self.component.output_stack.connect(
            "notify::visible-child", lambda switch, state: self.update_free_space()
        )
        self.component.size_combobox.connect(
            "changed", lambda _: self.update_free_space()
        )
thiolliere's avatar
thiolliere committed
402

thiolliere's avatar
thiolliere committed
403
        types = [info["typ"] for info in sd_card_info.informations]
thiolliere's avatar
thiolliere committed
404
405
406
        self.component.sd_card_list_store = Gtk.ListStore(*types)
        self.component.sd_card_combobox.set_model(self.component.sd_card_list_store)

thiolliere's avatar
thiolliere committed
407
        for counter in range(0, sd_card_info.visible_informations):
408
409
            cell_renderer = Gtk.CellRendererText()
            self.component.sd_card_combobox.pack_start(cell_renderer, True)
410
411
412
            self.component.sd_card_combobox.add_attribute(
                cell_renderer, "text", counter
            )
thiolliere's avatar
thiolliere committed
413

thiolliere's avatar
thiolliere committed
414
        # about dialog
415
416
417
        self.component.about_dialog.set_logo(
            GdkPixbuf.Pixbuf.new_from_file_at_scale(data.pibox_logo, 200, -1, True)
        )
418
        self.component.about_dialog.set_version(get_version_str())
thiolliere's avatar
thiolliere committed
419

thiolliere's avatar
thiolliere committed
420
        # done window
421
422
423
        self.component.done_window_ok_button.connect(
            "clicked", lambda widget: self.component.done_window.hide()
        )
424
425
426
        self.component.done_window.connect("delete-event", hide_on_delete)

        # space error window
427
428
429
        self.component.space_error_window_ok_button.connect(
            "clicked", self.space_error_window_ok_button_clicked
        )
430
        self.component.space_error_window.connect("delete-event", hide_on_delete)
thiolliere's avatar
thiolliere committed
431

thiolliere's avatar
thiolliere committed
432
        # run window
433
434
435
        self.component.run_installation_button.connect(
            "clicked", self.run_installation_button_clicked
        )
thiolliere's avatar
thiolliere committed
436
        self.component.run_window.connect("delete-event", self.run_window_delete_event)
437
438
439
        self.component.run_text_view.get_buffer().connect(
            "modified-changed", self.run_text_view_scroll_down
        )
440
        self.component.run_quit_button.connect("clicked", self.run_quit_button_clicked)
441
442
443
444
445
446
447
448
449
        self.component.run_abort_button.connect(
            "clicked", self.run_abort_button_clicked
        )
        self.component.run_copy_log_to_clipboard_button.connect(
            "clicked", self.run_copy_log_to_clipboard_button_clicked
        )
        self.component.run_new_install_button.connect(
            "clicked", self.run_new_install_button_clicked
        )
thiolliere's avatar
thiolliere committed
450

thiolliere's avatar
GUI    
thiolliere committed
451
        # zim content
452
453
454
        self.component.zim_choose_content_button.connect(
            "clicked", self.zim_choose_content_button_clicked
        )
thiolliere's avatar
GUI    
thiolliere committed
455

456
457
458
459
460
461
462
463
464
465
466
467
        self.component.zim_list_store = Gtk.ListStore(
            str,  # key
            str,  # name
            str,  # url
            str,  # description
            str,  # formatted_size
            object,  # languages
            str,  # type
            str,  # version
            bool,  # selected
            str,  # size
            bool,  # its language is selected
468
            Gdk.RGBA,  # background color
469
        )
thiolliere's avatar
thiolliere committed
470
        self.component.zim_list_store.set_sort_column_id(1, Gtk.SortType.ASCENDING)
thiolliere's avatar
GUI    
thiolliere committed
471

472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
        def get_project_size(name, lang):
            langs = ["fr", "en"] if name == "aflatoun" else [lang]
            return get_expanded_size(
                get_collection(**{"{}_languages".format(name): langs})
            )

        # kalite
        for lang, button in self.iter_kalite_check_button():
            button.set_label(
                "{} ({})".format(
                    button.get_label(),
                    human_readable_size(get_project_size("kalite", lang)),
                )
            )
            button.connect("toggled", lambda button: self.update_free_space())

        # wikifundi
        for lang, button in self.iter_wikifundi_check_button():
            button.set_label(
                "{} ({})".format(
                    button.get_label(),
                    human_readable_size(get_project_size("wikifundi", lang)),
                )
            )
            button.connect("toggled", lambda button: self.update_free_space())

        # aflatoun
        self.component.aflatoun_switch.connect(
            "notify::active", lambda switch, state: self.update_free_space()
        )
        self.component.aflatoun_label.set_label(
            "{} ({})".format(
                self.component.aflatoun_label.get_label(),
                human_readable_size(get_project_size("aflatoun", lang)),
            )
        )

        # edupi
        self.component.edupi_switch.connect(
            "notify::active", lambda switch, state: self.update_free_space()
        )
        self.component.edupi_label.set_label(
            "{} ({})".format(
                self.component.edupi_label.get_label(),
                human_readable_size(10 * ONE_MiB),
            )
        )
        self.component.edupi_resources_url_entry.connect(
            "changed", lambda _: self.update_free_space()
        )
        self.component.edupi_resources_chooser.connect(
            "file-set", lambda _: self.update_free_space()
        )

        self.refresh_disk_list()

        self.reset_config()  # will calculate free space

        self.component.window.show()

        self.catalogs_thread = threading.Thread(target=self.download_catalogs)
        self.catalogs_thread.start()

rgaudin's avatar
rgaudin committed
535
536
537
538
539
540
541
542
543
544
545
546
    def ensure_connection(self):
        """ test and return Connection Status. Display Error of failure """
        conn_working, failed_protocol = test_connection()
        if not conn_working:
            self.display_error_message(
                "Internet Connection Failed ({})".format(failed_protocol),
                "Unable to contact Kiwix Server.\nPlease check your Internet Connection and/or Proxy Settings (from the File menu).",
                self.component.window,
            )
            return False
        return True

547
548
549
550
551
552
    def download_catalogs(self):
        self.catalogs = get_catalogs(CLILogger())
        return self.catalogs is not None

    def ensure_catalogs(self):
        if self.catalogs_thread.is_alive():
rgaudin's avatar
rgaudin committed
553
            # let's wait for the catalog thread to complete
554
555
556
557
558
559
560
561
562
            self.catalogs_thread.join()
        if self.catalogs is None:
            if not self.download_catalogs():
                self.display_error_message(
                    title="Catalogs Download Failed",
                    message="Could not download the Content Catalogs. Please check your Internet connection and/or Proxy Settings (File menu).",
                    parent=self.component.window,
                )
                return False
rgaudin's avatar
rgaudin committed
563
564
565
        # now that we have the catalogs, build the ZIM store if not already done
        if not len(self.component.zim_list_store):
            self.build_zim_store()
566
567
568
        return True

    def build_zim_store(self):
thiolliere's avatar
thiolliere committed
569
        all_languages = set()
thiolliere's avatar
thiolliere committed
570

571
        for one_catalog in self.catalogs:
thiolliere's avatar
GUI    
thiolliere committed
572
573
574
575
            for (key, value) in one_catalog["all"].items():
                name = value["name"]
                url = value["url"]
                description = value.get("description") or "none"
576
                size = str(value["size"])
577
                languages = []
578
579
580
                for iso_code in (value.get("language") or "Unknown language").split(
                    ","
                ):
581
582
583
584
585
                    try:
                        languages.append(iso639.languages.get(part3=iso_code).name)
                    except KeyError:
                        pass
                languages = set(languages)
thiolliere's avatar
GUI    
thiolliere committed
586
587
                typ = value["type"]
                version = str(value["version"])
thiolliere's avatar
thiolliere committed
588
                formatted_size = human_readable_size(int(size))
thiolliere's avatar
GUI    
thiolliere committed
589

590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
                self.component.zim_list_store.append(
                    [
                        key,
                        name,
                        url,
                        description,
                        formatted_size,
                        languages,
                        typ,
                        version,
                        False,
                        size,
                        True,
                        VALID_RGBA,
                    ]
                )
thiolliere's avatar
thiolliere committed
606
                all_languages |= languages
thiolliere's avatar
GUI    
thiolliere committed
607

thiolliere's avatar
thiolliere committed
608
        self.component.zim_language_list_store = Gtk.ListStore(str)
609
610
611
        self.component.zim_language_list_store.set_sort_column_id(
            0, Gtk.SortType.ASCENDING
        )
thiolliere's avatar
thiolliere committed
612
        for language in all_languages:
thiolliere's avatar
thiolliere committed
613
614
            self.component.zim_language_list_store.append([language])

thiolliere's avatar
thiolliere committed
615
        # zim window
616
617
618
        self.component.zim_window_done_button.connect(
            "clicked", self.zim_done_button_clicked
        )
619
        self.component.zim_window.connect("delete-event", hide_on_delete)
620
621
622
623
624
625
        self.component.zim_tree_view.connect(
            "row-activated", self.available_zim_clicked
        )
        self.component.choosen_zim_tree_view.connect(
            "row-activated", self.choosen_zim_clicked
        )
thiolliere's avatar
thiolliere committed
626

627
        # zim window available tree view
thiolliere's avatar
thiolliere committed
628
629
        self.component.zim_tree_view.set_model(self.component.zim_list_store)

thiolliere's avatar
thiolliere committed
630
631
632
633
634
635
636
        renderer_text = Gtk.CellRendererText()
        column_text = Gtk.TreeViewColumn("Name", renderer_text, text=1)
        self.component.zim_tree_view.append_column(column_text)
        column_text = Gtk.TreeViewColumn("Size", renderer_text, text=4)
        self.component.zim_tree_view.append_column(column_text)
        column_text = Gtk.TreeViewColumn("Description", renderer_text, text=3)
        self.component.zim_tree_view.append_column(column_text)
637
        column_text.add_attribute(renderer_text, "cell_background_rgba", 11)
thiolliere's avatar
thiolliere committed
638

thiolliere's avatar
thiolliere committed
639
640
641
642
643
644
645
        zim_filter = self.component.zim_list_store.filter_new()
        zim_filter.set_visible_func(self.zim_filter_func)
        self.component.zim_tree_view.set_model(zim_filter)

        # zim window choosen tree view
        self.component.choosen_zim_tree_view.set_model(self.component.zim_list_store)

thiolliere's avatar
thiolliere committed
646
647
648
649
650
651
652
653
        renderer_text = Gtk.CellRendererText()
        column_text = Gtk.TreeViewColumn("Name", renderer_text, text=1)
        self.component.choosen_zim_tree_view.append_column(column_text)
        column_text = Gtk.TreeViewColumn("Size", renderer_text, text=4)
        self.component.choosen_zim_tree_view.append_column(column_text)
        column_text = Gtk.TreeViewColumn("Description", renderer_text, text=3)
        self.component.choosen_zim_tree_view.append_column(column_text)

thiolliere's avatar
thiolliere committed
654
655
656
657
658
        # language tree view
        renderer_text = Gtk.CellRendererText()
        column_text = Gtk.TreeViewColumn("Language", renderer_text, text=0)
        self.component.zim_language_tree_view.append_column(column_text)

659
660
661
662
663
664
        self.component.zim_language_tree_view.get_selection().set_mode(
            Gtk.SelectionMode(3)
        )
        self.component.zim_language_tree_view.set_model(
            self.component.zim_language_list_store
        )
thiolliere's avatar
thiolliere committed
665
        self.component.zim_language_tree_view.get_selection().select_all()
666
667
668
        self.component.zim_language_tree_view.get_selection().connect(
            "changed", self.zim_language_selection_changed
        )
thiolliere's avatar
thiolliere committed
669

670
671
672
673
        # apply chosen zim filter
        choosen_zim_filter = self.component.zim_list_store.filter_new()
        choosen_zim_filter.set_visible_func(self.choosen_zim_filter_func)
        self.component.choosen_zim_tree_view.set_model(choosen_zim_filter)
thiolliere's avatar
thiolliere committed
674

675
        self.update_free_space()
thiolliere's avatar
thiolliere committed
676

677
    def reset_config(self):
678
        """ restore UI to its initial (non-configured) state """
679
680

        # name
681
        self.component.project_name_entry.set_text("Kiwix")
682
683
684

        # language
        index = -1
rgaudin's avatar
rgaudin committed
685
        for i, (code, language) in enumerate(data.hotspot_languages):
686
            if code == "en":
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
                index = i
        self.component.language_combobox.set_active(index)

        # timezone
        default_id = -1
        local_tz = tzlocal.get_localzone()
        for id, timezone in enumerate(pytz.common_timezones):
            if timezone == "UTC" and default_id == -1:
                default_id = id
            if pytz.timezone(timezone) == local_tz:
                default_id = id
            self.component.timezone_tree_store.append(None, [timezone])
        self.component.timezone_combobox.set_active(default_id)

        # wifi
        self.component.wifi_password_switch.set_active(True)
rgaudin's avatar
rgaudin committed
703
        self.component.wifi_password_entry.set_text("hotspot-password")
704
705
706
707
708
709

        # admin account
        self.component.admin_account_login_entry.set_text("admin")
        self.component.admin_account_pwd_entry.set_text("admin-password")

        # branding
710
711
        for key in ("logo", "favicon", "css"):
            getattr(self.component, "{}_chooser".format(key)).unselect_all()
712
713
714
715
716

        # build_dir
        self.component.build_path_chooser.unselect_all()

        # size
717
        self.component.size_combobox.set_active(0)
718
719

        # content
720
721
        for key in ("kalite", "wikifundi"):
            for lang, button in getattr(self, "iter_{}_check_button".format(key))():
722
723
                button.set_active(False)

724
725
        for key in ("edupi", "aflatoun"):
            getattr(self.component, "{}_switch".format(key)).set_active(False)
726
727

        # edupi resources
728
        self.component.edupi_resources_url_entry.set_text("")
729
730
731
732
733
734
735
        self.component.edupi_resources_chooser.unselect_all()

        # static contents
        for index, zim in enumerate(self.component.zim_list_store):
            if zim[8]:
                self.component.zim_list_store[index][8] = False
        self.component.choosen_zim_tree_view.set_model(self.component.zim_list_store)
736
737
738
        choosen_zim_filter = self.component.zim_list_store.filter_new()
        choosen_zim_filter.set_visible_func(self.choosen_zim_filter_func)
        self.component.choosen_zim_tree_view.set_model(choosen_zim_filter)
739
740
741

        self.update_free_space()

742
    def iter_kalite_check_button(self):
743
744
745
746
747
        return [
            ("fr", self.component.kalite_fr_check_button),
            ("en", self.component.kalite_en_check_button),
            ("es", self.component.kalite_es_check_button),
        ]
748

thiolliere's avatar
thiolliere committed
749
    def iter_wikifundi_check_button(self):
750
751
752
753
        return [
            ("fr", self.component.wikifundi_fr_check_button),
            ("en", self.component.wikifundi_en_check_button),
        ]
thiolliere's avatar
thiolliere committed
754

thiolliere's avatar
thiolliere committed
755
756
757
    def space_error_window_ok_button_clicked(self, widget):
        self.component.space_error_window.hide()

thiolliere's avatar
thiolliere committed
758
759
    def activate_menu_about(self, widget):
        response = self.component.about_dialog.run()
760
761
762
763
        if (
            response == Gtk.ResponseType.DELETE_EVENT
            or response == Gtk.ResponseType.CANCEL
        ):
thiolliere's avatar
thiolliere committed
764
765
            self.component.about_dialog.hide()

766
767
768
    def activate_menu_help(self, widget):
        webbrowser.open(data.help_url)

rgaudin's avatar
rgaudin committed
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
    def _set_proxies_entries(self, proxies=None):
        """ fill proxies_dialog entries with proxies conf (passed or prefs) """
        proxies = proxies if proxies is not None else get_proxies()
        http_loc, http_port = split_proxy(proxies.get("http", ""))
        self.component.http_proxy_entry.set_text(http_loc)
        self.component.http_proxy_port_entry.set_text(http_port)

        https_loc, https_port = split_proxy(proxies.get("https", ""))
        self.component.https_proxy_entry.set_text(https_loc)
        self.component.https_proxy_port_entry.set_text(https_port)

    def _get_proxies_entries(self):
        """ return proxies conf from the proxies_dialog entries """
        http_proxy = self.component.http_proxy_entry.get_text().strip()
        http_proxy_port = self.component.http_proxy_port_entry.get_text().strip()
        https_proxy = self.component.https_proxy_entry.get_text().strip()
        https_proxy_port = self.component.https_proxy_port_entry.get_text().strip()

        proxies = {}
        if http_proxy and http_proxy_port:
            proxies.update(
                {
                    "http": "http://{netloc}:{port}".format(
                        netloc=http_proxy, port=http_proxy_port
                    )
                }
            )
        if https_proxy and https_proxy_port:
            proxies.update(
                {
                    "https": "http://{netloc}:{port}".format(
                        netloc=https_proxy, port=https_proxy_port
                    )
                }
            )
        return proxies

    def test_proxies_button_clicked(self, widget):
        """ test connection using the (non-saved) proxy conf in the proxies dialog """
        proxies = self._get_proxies_entries()
        conn_working, failed_protocol = test_connection(proxies=proxies)
        if conn_working:
            mtype = Gtk.MessageType.INFO
            title = "Connection Successful"
            message = (
                "We could reach Kiwix server using those settings.\n"
                "You can now save them and pursue."
            )
        else:
            mtype = Gtk.MessageType.ERROR
            title = "Connection Failed ({})".format(failed_protocol)
            message = (
                "Unable to contact Kiwix server using those Settings.\n"
                "Either your Internet Connection is not working or those settings are incorrect."
            )

        msg_box = Gtk.MessageDialog(
            self.component.proxies_dialog, None, mtype, Gtk.ButtonsType.OK, title
        )
        msg_box.format_secondary_text(message)
        msg_box.set_modal(True)
        msg_box.run()
        msg_box.destroy()

    def reset_proxies_button_clicked(self, widget):
        """ set proxies conf and prefs to not use proxy at all """

        # reset UI
        self._set_proxies_entries({})

        # save prefs and reload proxies
        save_prefs({}, auto_reload=True)
        get_proxies(load_env=False, force_reload=True)

        # close dialog
        self.component.proxies_dialog.hide()

    def save_proxies_button_clicked(self, widget):
        """ save in prefs and use proxies conf from proxies_dialog """

        proxies = self._get_proxies_entries()
        prefs = {}

        if proxies.get("http"):
            prefs.update({"HTTP_PROXY": proxies.get("http")})

        if proxies.get("https"):
            prefs.update({"HTTPS_PROXY": proxies.get("https")})

        # save prefs and reload proxies
        save_prefs(prefs, auto_reload=True)
        get_proxies(load_env=False, force_reload=True)

        # reflect changes on UI
        self._set_proxies_entries()

        # close dialog
        self.component.proxies_dialog.hide()

868
869
870
871
    def activate_menu_imdisk(self, widget):
        class ImDiskDialog(Gtk.Dialog):
            def __init__(self, parent):
                Gtk.Dialog.__init__(
872
873
874
875
876
877
878
879
880
881
882
                    self,
                    "Install or Uninstall ImDisk Manually",
                    parent,
                    0,
                    (
                        Gtk.STOCK_CANCEL,
                        Gtk.ResponseType.CANCEL,
                        Gtk.STOCK_OK,
                        Gtk.ResponseType.OK,
                    ),
                )
883
884
885
886
887
888
889
890

                self.set_default_size(500, 300)

                label = Gtk.Label()
                label.set_markup(
                    "\nBy selecting OK bellow, you will be directed to the "
                    "ImDisk installation folder.\n"
                    "<b>Right-click on <i>install.cmd</i></b> and choose to "
891
892
                    "<u>Run as Administrator</u>.\n"
                )
893
894
895
896
                label.set_alignment(0, 0.5)
                label2 = Gtk.Label()
                label2.set_markup(
                    "\nYou can also uninstall it from that folder by "
897
898
                    "doing the same with <i>uninstall_imdisk.cmd</i>.\n"
                )
899
900
                label2.set_alignment(0, 0.5)
                image = Gtk.Image.new_from_file(
901
902
                    os.path.join(data.data_dir, "imdisk.png")
                )
903
904
905
906
907
                box = self.get_content_area()
                box.add(label)
                box.add(image)
                box.add(label2)
                self.show_all()
908

909
910
911
912
913
        dialog = ImDiskDialog(self.component.window)
        if dialog.run() == Gtk.ResponseType.OK:
            open_explorer_for_imdisk(self.logger)
        dialog.close()

rgaudin's avatar
rgaudin committed
914
915
916
917
918
919
    def activate_menu_etcher(self, widget):
        class EtcherDialog(Gtk.Dialog):
            DL_CODE = 2111

            def __init__(self, parent):
                Gtk.Dialog.__init__(
920
921
922
923
924
925
926
927
928
929
930
931
932
                    self,
                    "Use Etcher to Flash your SD-card",
                    parent,
                    0,
                    (
                        "Download Latest Etcher",
                        self.DL_CODE,
                        "Visit Website",
                        Gtk.ResponseType.OK,
                        Gtk.STOCK_OK,
                        Gtk.ResponseType.CANCEL,
                    ),
                )
rgaudin's avatar
rgaudin committed
933
934
935
936
937
938
939
940
941

                self.set_default_size(500, 300)

                label = Gtk.Label()
                label.set_markup(
                    "\nUse <b>Etcher</b> to flash your image onto your SD-card"
                    ". It will also <b>validate</b> that the SD-card "
                    "has been <b>successfuly written</b>.\n"
                    "You can even burn the same image "
942
943
                    "on <b>several SD-cards at once</b>.\n"
                )
rgaudin's avatar
rgaudin committed
944
945
946
                label.set_alignment(0, 0.5)
                label2 = Gtk.Label()
                label2.set_markup(
947
948
                    "\nPlease Download and Run <b>Etcher</b> separately.\n"
                )
rgaudin's avatar
rgaudin committed
949
950
                label2.set_alignment(0, 0.5)
                image = Gtk.Image.new_from_file(
951
952
                    os.path.join(data.data_dir, "etcher.gif")
                )
rgaudin's avatar
rgaudin committed
953
954
955
956
957
                box = self.get_content_area()
                box.add(label)
                box.add(image)
                box.add(label2)
                self.show_all()
958

rgaudin's avatar
rgaudin committed
959
960
961
962
        dialog = EtcherDialog(self.component.window)
        ret = dialog.run()
        if ret == EtcherDialog.DL_CODE:
            try:
963
964
965
                req = requests.get(
                    "https://img.shields.io/github/release" "/resin-io/etcher.json"
                )
rgaudin's avatar
rgaudin committed
966
                version = req.json().get("value")
967
968
969
970
                base_url = (
                    "https://github.com/resin-io/etcher/releases/"
                    "download/{}/".format(version)
                )
rgaudin's avatar
rgaudin committed
971
972
973

                def get_fname():
                    if sys.platform == "linux":
974
                        if platform.machine() == "x86_64":
rgaudin's avatar
rgaudin committed
975
976
977
                            return "etcher-electron-{v}-x86_64.AppImage"
                        return "etcher-electron-{v}-i386.AppImage"
                    elif sys.platform == "win32":
978
                        if platform.machine() == "AMD64":
rgaudin's avatar
rgaudin committed
979
980
981
982
983
984
985
986
987
988
989
990
991
992
                            return "Etcher-Portable-{v}-x64.exe"
                        return "Etcher-Portable-{v}-x86.exe"
                    elif sys.platform == "darwin":
                        return "Etcher-{v}.dmg"
                    raise NotImplementedError("platform not supported")

                etcher_dl_url = base_url + get_fname().format(v=version[1:])
            except Exception as exp:
                etcher_dl_url = data.etcher_url
            webbrowser.open(etcher_dl_url)
        elif ret == Gtk.ResponseType.OK:
            webbrowser.open(data.etcher_url)
        dialog.close()

993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
    def changed_build_path(self, widget):
        """ display Clean cache button only if build-path is set """
        self.component.clean_cache_button.set_visible(
            bool(self.component.build_path_chooser.get_filename().strip())
        )

    def activate_menu_cache(self, widget):
        build_folder = self.component.build_path_chooser.get_filename()
        cache_folder = get_cache(build_folder)
        cache_size, nb_files, free_space = get_cache_size_and_free_space(
            build_folder, cache_folder
        )

        class CacheDialog(Gtk.Dialog):
            WIPE_CODE = 2111

            def __init__(self, parent):
                Gtk.Dialog.__init__(
                    self,
                    "Reclaim space by cleaning-up your cache",
                    parent,
                    0,
                    (
                        "Wipe Cache (quick)",
                        self.WIPE_CODE,
                        "Clean Cache (slow)",
                        Gtk.ResponseType.OK,
                        "Close",
                        Gtk.ResponseType.CANCEL,
                    ),
                )

                self.parent = parent
                self.set_default_size(300, 100)

                label = Gtk.Label()
                label.set_markup(
                    "\nKiwix Hotspot maintains <b>a cache of all downloaded files</b> "
                    "and reuse them on future runs.\n"
                    "\nYou can either <b>wipe the cache completely</b> "
                    "or <b>only remove obsolete files</b>.\n"
                    "Obsoletes files are previous version of ZIMs or content packs.\n\n"
                    "Wiping is almost instantaneous.\n"
                    "Cleaning takes several minutes as "
                    "it analyzes files to determine which ones should be kept.\n\n"
                    "Your cache folder is: <i>{cache}</i>.\n"
                    "<u>Cache Disk Usage</u>: <b>{du}</b> ({nb} files)\n"
                    "<u>Free Space</u>: <b>{df}</b>\n".format(
                        cache=cache_folder,
                        du=human_readable_size(cache_size),
                        df=human_readable_size(free_space),
                        nb=nb_files,
                    )
                )
                label.set_alignment(0, 0.5)

                self.thread = None
                self.run_progressbar = Gtk.ProgressBar()
                self.cancel_button = Gtk.Button("Cancel")
                self.cancel_button.connect("clicked", self.stop_cache_operation)

                box = self.get_content_area()
                box.add(label)
                box.add(self.run_progressbar)
                box.add(self.cancel_button)
                box.add(Gtk.Label(""))  # spacer
                self.show_all()
                self.run_progressbar.set_visible(False)
                self.cancel_button.set_visible(False)

            def start_cache_operation(self, is_wipe):
                # do nothing if the thread is running
                if self.thread is not None and self.thread.is_alive():
                    return

                # show progress bar and cancel button
                self.run_progressbar.set_visible(True)
                self.cancel_button.set_label(
                    "Cancel {}".format("Wiping" if is_wipe else "Cleaning...")
                )
                self.cancel_button.set_visible(True)

                # start progress bar animation
                self.timeout_id = GObject.timeout_add(50, self.on_timeout)

                self.thread = multiprocessing.Process(
                    target=reset_cache if is_wipe else clean_cache,
                    args=(CLILogger(), build_folder, cache_folder),
                )
                self.thread.start()

            def on_timeout(self):
                # display post-thread dialog on cancelled thread
                if self.thread is not None and not self.thread.is_alive():
                    self.display_post_cache_operation_dialog()
                    return False
                elif self.thread is None:  # stop progress anim if thread not running
                    return False

                new_value = self.run_progressbar.get_fraction() + 0.035
                # inverse direction if end reached
                if new_value > 1:
                    new_value = 0
                    # switch from left-to-right to right-to-left at bounds
                    self.run_progressbar.set_inverted(
                        not self.run_progressbar.get_inverted()
                    )
                self.run_progressbar.set_fraction(new_value)
                return True  # returns True so it continues to get called

            def stop_cache_operation(self, *args, **kwargs):
                if self.thread is not None and self.thread.is_alive():
                    self.thread.terminate()
                    self.thread = None
                    self.cancel_button.set_visible(False)
                    self.run_progressbar.set_visible(False)
                self.close()

            def display_post_cache_operation_dialog(self):
                msg_box = Gtk.MessageDialog(
                    self.parent,
                    None,
                    Gtk.MessageType.INFO,
                    Gtk.ButtonsType.OK,
                    "Cache Operation Completed",
                )
                cache_size, nb_files, free_space = get_cache_size_and_free_space(
                    build_folder, cache_folder
                )
                content = (
                    "Cache folder: {cache}.\n"
                    "Cache Disk Usage: {du} ({nb} files)\n"
                    "Free Space: {df}\n\n".format(
                        cache=cache_folder,
                        du=human_readable_size(cache_size),
                        df=human_readable_size(free_space),
                        nb=nb_files,
                    )
                )
                msg_box.format_secondary_text(content)
                msg_box.set_modal(True)
                msg_box.run()
                msg_box.destroy()
                self.close()

        dialog = CacheDialog(self.component.window)
        ret = dialog.run()
        if ret == CacheDialog.WIPE_CODE:
            dialog.start_cache_operation(True)
        elif ret == Gtk.ResponseType.OK:
            dialog.start_cache_operation(False)
        else:
            dialog.close()

1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
    def activate_sd_clean(self, widget):
        sd_card = self.get_sd_card()

        class SDCleanDialog(Gtk.Dialog):
            def __init__(self, parent, parent_ui):
                Gtk.Dialog.__init__(
                    self,
                    "Wipe your SD-card clean before for installation",
                    parent,
                    0,
                    (
                        "Wipe {}".format(sd_card),
                        Gtk.ResponseType.OK,
                        "Close",
                        Gtk.ResponseType.CANCEL,
                    ),
                )

                self.parent = parent
                self.parent_ui = parent_ui
                self.set_default_size(300, 100)

                label = Gtk.Label()
                label.set_markup(
                    "\nFor Kiwix Hotspot to work properly,\n"
                    "you need your SD-card to be cleaned before starting,\n"
                    "hence having just a single FAT-like partition.\n\n"
                    "This process you only take a few minutes.\n"
                    "If this does not end within 10mn,\n"
                    "cancel-it and try clean your SD-card using a different tool.\n\n"
                )
                label.set_alignment(0, 0.5)

                self.thread = None
                self.retcode = multiprocessing.Value("i", -1)
                self.run_progressbar = Gtk.ProgressBar()
                self.cancel_button = Gtk.Button("Cancel")
                self.cancel_button.connect("clicked", self.stop_clean_operation)

                box = self.get_content_area()
                box.add(label)
                box.add(self.run_progressbar)
                box.add(self.cancel_button)
                box.add(Gtk.Label(""))  # spacer
                self.show_all()
                self.run_progressbar.set_visible(False)
                self.cancel_button.set_visible(False)

            def start_clean_operation(self):
                # do nothing if the thread is running
                if self.thread is not None and self.thread.is_alive():
                    return

                # show progress bar and cancel button
                self.run_progressbar.set_visible(True)
                self.cancel_button.set_label("Cancel Wiping")
                self.cancel_button.set_visible(True)

                # start progress bar animation
                self.timeout_id = GObject.timeout_add(50, self.on_timeout)

                self.thread = multiprocessing.Process(
                    target=flash_image_with_etcher,
                    args=(
                        os.path.join(data.data_dir, "mbr.img"),
                        sd_card,
                        self.retcode,
                    ),
                )
                self.thread.start()

            def on_timeout(self):
                # display post-thread dialog on cancelled thread
                if self.thread is not None and not self.thread.is_alive():
                    self.display_post_clean_operation_dialog()
                    return False
                elif self.thread is None:  # stop progress anim if thread not running
                    return False

                new_value = self.run_progressbar.get_fraction() + 0.035
                # inverse direction if end reached
                if new_value > 1:
                    new_value = 0
                    # switch from left-to-right to right-to-left at bounds
                    self.run_progressbar.set_inverted(
                        not self.run_progressbar.get_inverted()
                    )
                self.run_progressbar.set_fraction(new_value)
                return True  # returns True so it continues to get called

            def stop_clean_operation(self, *args, **kwargs):
                if self.thread is not None and self.thread.is_alive():
                    self.thread.terminate()
                    self.thread = None
                    self.cancel_button.set_visible(False)
                    self.run_progressbar.set_visible(False)
                self.close()

            def display_post_clean_operation_dialog(self):
                if self.retcode.value == 0:
                    title = "SD-card Cleaning Completed"
                    content = (
                        "Your SD-card ({}) has been wiped.\n\n"
                        "You now need to unplug then replug your device.\n"
                        "Once done, come back and hit the refresh button."
                    ).format(sd_card)
                else:
                    title = "SD-card Cleaning Failed"
                    content = (
                        "You SD-card HAS NOT been wiped.\n\n"
                        "Please use a different tool to clean it."
                    )

                msg_box = Gtk.MessageDialog(
                    self.parent, None, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, title
                )

                msg_box.format_secondary_text(content)
                msg_box.set_modal(True)
                msg_box.run()
                msg_box.destroy()
                self.close()
                self.parent_ui.sd_card_refresh_button_clicked("")

        dialog = SDCleanDialog(self.component.window, self)
        ret = dialog.run()
        if ret == Gtk.ResponseType.OK:
            dialog.start_clean_operation()
        else:
            dialog.close()
            self.sd_card_refresh_button_clicked("")

thiolliere's avatar
thiolliere committed
1279
    def installation_done(self, error):
rgaudin's avatar
rgaudin committed
1280
        ok = error is None
1281
1282
1283
1284
        validate_label(self.component.done_label, ok)
        if ok:
            self.component.done_label.set_text("Installation done")
        else:
thiolliere's avatar
thiolliere committed
1285
            self.component.done_label.set_text("Installation failed")
1286

thiolliere's avatar
thiolliere committed
1287
        self.component.done_window.show()
1288
1289
        self.component.run_install_running_buttons_revealer.set_reveal_child(False)
        self.component.run_install_done_buttons_revealer.set_reveal_child(True)
thiolliere's avatar
thiolliere committed
1290
1291

    def run_text_view_scroll_down(self, widget):
thiolliere's avatar
thiolliere committed
1292
1293
1294
1295
1296
1297
1298
1299
        text_buffer = self.component.run_text_view.get_buffer()
        text_buffer.set_modified(False)

        end = text_buffer.get_end_iter()
        end.backward_line()

        self.component.run_text_view.scroll_to_iter(end, 0, True, 0, 1.)

thiolliere's avatar
thiolliere committed
1300
    def run_window_delete_event(self, widget, path):
1301
1302
1303
        return True

    def cancel_run(self):
thiolliere's avatar
thiolliere committed
1304
        self.cancel_event.cancel()
1305
        quit()
thiolliere's avatar
thiolliere committed
1306

1307
    def run_quit_button_clicked(self, widget):
1308
        self.cancel_run()
1309
1310

    def run_abort_button_clicked(self, widget):
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
        dialog = ShortDialog(
            self.component.run_window,
            (
                Gtk.STOCK_CANCEL,
                Gtk.ResponseType.CANCEL,
                Gtk.STOCK_OK,
                Gtk.ResponseType.OK,
            ),
            "Are you sure you want to abort the installation ?\nyou will not be able to resume.",
        )
1321
1322
1323
        response = dialog.run()

        if response == Gtk.ResponseType.OK:
1324
            self.cancel_run()
1325
1326

        dialog.destroy()
thiolliere's avatar
GUI    
thiolliere committed
1327

1328
    def run_new_install_button_clicked(self, widget):
1329
        self.logger.reset()
1330
1331
1332
        self.component.run_window.hide()
        self.component.window.show()

1333
    def display_error_message(self, title, message=None, parent=None, flags=None):
1334
1335
1336
        if parent is None:
            parent = self.component.window
        dialog = Gtk.MessageDialog(
1337
1338
            parent, flags, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, title
        )
1339
1340
1341
1342
1343
1344
1345
        if message is not None:
            dialog.format_secondary_text(message)
        dialog.set_modal(True)
        dialog.run()
        dialog.destroy()

    def activate_menu_config(self, widget, for_save=False):
1346
        home_path = os.environ["HomePath" if sys.platform == "win32" else "HOME"]
1347

1348
1349
1350
        if not for_save and not self.ensure_catalogs():
            return

1351
        def _save(dialog):
1352
1353
1354
            filename = (
                dialog.get_filename()
                if dialog.get_filename().endswith(".json")
1355
                else "{}.json".format(dialog.get_filename())
1356
            )
1357
            try:
rgaudin's avatar
rgaudin committed
1358
                with open(filename, "w", encoding="utf-8") as fd:
rgaudin's avatar