gui.py 77 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

rgaudin's avatar
rgaudin committed
53
54
VALID_RGBA = Gdk.RGBA(0.0, 0.0, 0.0, 0.0)
INVALID_RGBA = Gdk.RGBA(1, 0.5, 0.5, 1.0)
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
    @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:
rgaudin's avatar
rgaudin committed
115
            ratio = min(float(current) / total, 1.0)
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
            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
        def get_project_size(name, lang):
            langs = ["fr", "en"] if name == "aflatoun" else [lang]
            return get_expanded_size(
475
                get_collection(**{"{}_languages".format(name): langs}), add_margin=False
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
            )

        # 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()
        )

526
527
528
529
530
531
532
533
534
535
536
537
538
        # nomad
        self.component.nomad_switch.connect(
            "notify::active", lambda switch, state: self.update_free_space()
        )
        self.component.nomad_label.set_label(
            "{} ({})".format(
                self.component.nomad_label.get_label(),
                human_readable_size(
                    get_expanded_size(get_collection(nomad=True), add_margin=False)
                ),
            )
        )

539
540
541
542
543
544
545
546
547
548
549
550
551
        # mathews
        self.component.mathews_switch.connect(
            "notify::active", lambda switch, state: self.update_free_space()
        )
        self.component.mathews_label.set_label(
            "{} ({})".format(
                self.component.mathews_label.get_label(),
                human_readable_size(
                    get_expanded_size(get_collection(mathews=True), add_margin=False)
                ),
            )
        )

552
553
554
555
556
557
558
559
560
        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
561
562
563
564
565
566
567
568
569
570
571
572
    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

573
574
575
576
577
578
    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
579
            # let's wait for the catalog thread to complete
580
581
582
583
584
585
586
587
588
            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
589
590
591
        # 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()
592
593
594
        return True

    def build_zim_store(self):
thiolliere's avatar
thiolliere committed
595
        all_languages = set()
thiolliere's avatar
thiolliere committed
596

597
        for one_catalog in self.catalogs:
thiolliere's avatar
GUI    
thiolliere committed
598
599
600
601
            for (key, value) in one_catalog["all"].items():
                name = value["name"]
                url = value["url"]
                description = value.get("description") or "none"
602
                size = str(value["size"])
603
                languages = []
604
605
606
                for iso_code in (value.get("language") or "Unknown language").split(
                    ","
                ):
607
608
609
610
611
                    try:
                        languages.append(iso639.languages.get(part3=iso_code).name)
                    except KeyError:
                        pass
                languages = set(languages)
thiolliere's avatar
GUI    
thiolliere committed
612
613
                typ = value["type"]
                version = str(value["version"])
thiolliere's avatar
thiolliere committed
614
                formatted_size = human_readable_size(int(size))
thiolliere's avatar
GUI    
thiolliere committed
615

616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
                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
632
                all_languages |= languages
thiolliere's avatar
GUI    
thiolliere committed
633

thiolliere's avatar
thiolliere committed
634
        self.component.zim_language_list_store = Gtk.ListStore(str)
635
636
637
        self.component.zim_language_list_store.set_sort_column_id(
            0, Gtk.SortType.ASCENDING
        )
thiolliere's avatar
thiolliere committed
638
        for language in all_languages:
thiolliere's avatar
thiolliere committed
639
640
            self.component.zim_language_list_store.append([language])

thiolliere's avatar
thiolliere committed
641
        # zim window
642
643
644
        self.component.zim_window_done_button.connect(
            "clicked", self.zim_done_button_clicked
        )
645
        self.component.zim_window.connect("delete-event", hide_on_delete)
646
647
648
649
650
651
        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
652

653
        # zim window available tree view
thiolliere's avatar
thiolliere committed
654
655
        self.component.zim_tree_view.set_model(self.component.zim_list_store)

thiolliere's avatar
thiolliere committed
656
657
658
659
660
661
662
        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)
663
        column_text.add_attribute(renderer_text, "cell_background_rgba", 11)
thiolliere's avatar
thiolliere committed
664

thiolliere's avatar
thiolliere committed
665
666
667
668
669
670
671
        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
672
673
674
675
676
677
678
679
        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
680
681
682
683
684
        # 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)

685
686
687
688
689
690
        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
691
        self.component.zim_language_tree_view.get_selection().select_all()
692
693
694
        self.component.zim_language_tree_view.get_selection().connect(
            "changed", self.zim_language_selection_changed
        )
thiolliere's avatar
thiolliere committed
695

696
697
698
699
        # 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
700

701
        self.update_free_space()
thiolliere's avatar
thiolliere committed
702

703
    def reset_config(self):
704
        """ restore UI to its initial (non-configured) state """
705
706

        # name
707
        self.component.project_name_entry.set_text("Kiwix")
708
709
710

        # language
        index = -1
rgaudin's avatar
rgaudin committed
711
        for i, (code, language) in enumerate(data.hotspot_languages):
712
            if code == "en":
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
                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
729
        self.component.wifi_password_entry.set_text("hotspot-password")
730
731
732
733
734
735

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

        # branding
736
737
        for key in ("logo", "favicon", "css"):
            getattr(self.component, "{}_chooser".format(key)).unselect_all()
738
739
740
741
742

        # build_dir
        self.component.build_path_chooser.unselect_all()

        # size
743
        self.component.size_combobox.set_active(0)
744
745

        # content
746
747
        for key in ("kalite", "wikifundi"):
            for lang, button in getattr(self, "iter_{}_check_button".format(key))():
748
749
                button.set_active(False)

750
        for key in ("edupi", "aflatoun", "nomad", "mathews"):
751
            getattr(self.component, "{}_switch".format(key)).set_active(False)
752
753

        # edupi resources
754
        self.component.edupi_resources_url_entry.set_text("")
755
756
757
758
759
760
761
        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)
762
763
764
        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)
765
766
767

        self.update_free_space()

768
    def iter_kalite_check_button(self):
769
770
771
772
773
        return [
            ("fr", self.component.kalite_fr_check_button),
            ("en", self.component.kalite_en_check_button),
            ("es", self.component.kalite_es_check_button),
        ]
774

thiolliere's avatar
thiolliere committed
775
    def iter_wikifundi_check_button(self):
776
777
778
779
        return [
            ("fr", self.component.wikifundi_fr_check_button),
            ("en", self.component.wikifundi_en_check_button),
        ]
thiolliere's avatar
thiolliere committed
780

thiolliere's avatar
thiolliere committed
781
782
783
    def space_error_window_ok_button_clicked(self, widget):
        self.component.space_error_window.hide()

thiolliere's avatar
thiolliere committed
784
785
    def activate_menu_about(self, widget):
        response = self.component.about_dialog.run()
786
787
788
789
        if (
            response == Gtk.ResponseType.DELETE_EVENT
            or response == Gtk.ResponseType.CANCEL
        ):
thiolliere's avatar
thiolliere committed
790
791
            self.component.about_dialog.hide()

792
793
794
    def activate_menu_help(self, widget):
        webbrowser.open(data.help_url)

rgaudin's avatar
rgaudin committed
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
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
    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()

894
895
896
897
    def activate_menu_imdisk(self, widget):
        class ImDiskDialog(Gtk.Dialog):
            def __init__(self, parent):
                Gtk.Dialog.__init__(
898
899
900
901
902
903
904
905
906
907
908
                    self,
                    "Install or Uninstall ImDisk Manually",
                    parent,
                    0,
                    (
                        Gtk.STOCK_CANCEL,
                        Gtk.ResponseType.CANCEL,
                        Gtk.STOCK_OK,
                        Gtk.ResponseType.OK,
                    ),
                )
909
910
911
912
913
914
915
916

                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 "
917
918
                    "<u>Run as Administrator</u>.\n"
                )
919
920
921
922
                label.set_alignment(0, 0.5)
                label2 = Gtk.Label()
                label2.set_markup(
                    "\nYou can also uninstall it from that folder by "
923
924
                    "doing the same with <i>uninstall_imdisk.cmd</i>.\n"
                )
925
926
                label2.set_alignment(0, 0.5)
                image = Gtk.Image.new_from_file(
927
928
                    os.path.join(data.data_dir, "imdisk.png")
                )
929
930
931
932
933
                box = self.get_content_area()
                box.add(label)
                box.add(image)
                box.add(label2)
                self.show_all()
934

935
936
937
938
939
        dialog = ImDiskDialog(self.component.window)
        if dialog.run() == Gtk.ResponseType.OK:
            open_explorer_for_imdisk(self.logger)
        dialog.close()

rgaudin's avatar
rgaudin committed
940
941
942
943
944
945
    def activate_menu_etcher(self, widget):
        class EtcherDialog(Gtk.Dialog):
            DL_CODE = 2111

            def __init__(self, parent):
                Gtk.Dialog.__init__(
946
947
948
949
950
951
952
953
954
955
956
957
958
                    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
959
960
961
962
963
964
965
966
967

                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 "
968
969
                    "on <b>several SD-cards at once</b>.\n"
                )
rgaudin's avatar
rgaudin committed
970
971
972
                label.set_alignment(0, 0.5)
                label2 = Gtk.Label()
                label2.set_markup(
973
974
                    "\nPlease Download and Run <b>Etcher</b> separately.\n"
                )
rgaudin's avatar
rgaudin committed
975
976
                label2.set_alignment(0, 0.5)
                image = Gtk.Image.new_from_file(
977
978
                    os.path.join(data.data_dir, "etcher.gif")
                )
rgaudin's avatar
rgaudin committed
979
980
981
982
983
                box = self.get_content_area()
                box.add(label)
                box.add(image)
                box.add(label2)
                self.show_all()
984

rgaudin's avatar
rgaudin committed
985
986
987
988
        dialog = EtcherDialog(self.component.window)
        ret = dialog.run()
        if ret == EtcherDialog.DL_CODE:
            try:
989
990
991
                req = requests.get(
                    "https://img.shields.io/github/release" "/resin-io/etcher.json"
                )
rgaudin's avatar
rgaudin committed
992
                version = req.json().get("value")
993
994
995
996
                base_url = (
                    "https://github.com/resin-io/etcher/releases/"
                    "download/{}/".format(version)
                )
rgaudin's avatar
rgaudin committed
997
998
999

                def get_fname():
                    if sys.platform == "linux":
1000
                        if platform.machine() == "x86_64":
rgaudin's avatar
rgaudin committed
1001
1002
1003
                            return "etcher-electron-{v}-x86_64.AppImage"
                        return "etcher-electron-{v}-i386.AppImage"
                    elif sys.platform == "win32":
1004
                        if platform.machine() == "AMD64":
rgaudin's avatar
rgaudin committed
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
                            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()

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
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
    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()

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
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
    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
1305
    def installation_done(self, error):
rgaudin's avatar
rgaudin committed
1306
        ok = error is None
1307
1308
1309
1310
        validate_label(self.component.done_label, ok)
        if ok:
            self.component.done_label.set_text("Installation done")
        else:
thiolliere's avatar
thiolliere committed
1311
            self.component.done_label.set_text("Installation failed")
1312

thiolliere's avatar
thiolliere committed
1313
        self.component.done_window.show()
1314
1315
        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
1316
1317

    def run_text_view_scroll_down(self, widget):
thiolliere's avatar
thiolliere committed
1318
1319
1320
1321
1322
1323
        text_buffer = self.component.run_text_view.get_buffer()
        text_buffer.set_modified(False)

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

rgaudin's avatar
rgaudin committed
1324
        self.component.run_text_view.scroll_to_iter(end, 0, True, 0, 1.0)
thiolliere's avatar
thiolliere committed
1325

thiolliere's avatar
thiolliere committed
1326
    def run_window_delete_event(self, widget, path):
1327
1328
1329
        return True

    def cancel_run(self):
thiolliere's avatar
thiolliere committed
1330
        self.cancel_event.cancel()
1331
        quit()
thiolliere's avatar
thiolliere committed
1332

1333
    def run_quit_button_clicked(self, widget):
1334
        self.cancel_run()
1335
1336

    def run_abort_button_clicked(self, widget):
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
        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.",
        )
1347
1348
1349
        response = dialog.run()

        if response == Gtk.ResponseType.OK:
1350
            self.cancel_run()
1351
1352

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

1354
    def run_new_install_button_clicked(self, widget):
1355
        self.logger.reset()
1356
1357
1358
        self.component.run_window.hide()
        self.component.window.show()

1359
    def display_error_message(self, title, message=None, parent=None, flags=None):
1360
1361
1362
        if parent is None:
            parent = self.component.window
        dialog = Gtk.MessageDialog(
1363
1364
            parent, flags, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, title
        )