Skip to content

Commit d8442bf

Browse files
committed
feature: "import/export" (1. iteration)
- import and export menu entry - new shortcuts for the import and the export tool - a new tool window (ei_tool.ui) - import and export handler in common.py
1 parent 7a416cb commit d8442bf

4 files changed

Lines changed: 507 additions & 2 deletions

File tree

usr/lib/webapp-manager/common.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
import threading
1818
import traceback
1919
from typing import Optional
20+
import tarfile
2021

2122
# 2. Related third party imports.
22-
from gi.repository import GObject
23+
from gi.repository import GObject, GLib
2324
import PIL.Image
2425
import requests
2526
# Note: BeautifulSoup is an optional import supporting another way of getting a website's favicons.
@@ -63,6 +64,19 @@ def wrapper(*args):
6364
ICONS_DIR = os.path.join(ICE_DIR, "icons")
6465
BROWSER_TYPE_FIREFOX, BROWSER_TYPE_FIREFOX_FLATPAK, BROWSER_TYPE_FIREFOX_SNAP, BROWSER_TYPE_LIBREWOLF_FLATPAK, BROWSER_TYPE_WATERFOX_FLATPAK, BROWSER_TYPE_FLOORP_FLATPAK, BROWSER_TYPE_CHROMIUM, BROWSER_TYPE_EPIPHANY, BROWSER_TYPE_FALKON = range(9)
6566

67+
class ei_task:
68+
def __init__(self, result_callback, update_callback, builder, webAppLauncherSelf, window, stop_event, task):
69+
self.result_callback = result_callback
70+
self.update_callback = update_callback
71+
self.builder = builder
72+
self.webAppLauncherSelf = webAppLauncherSelf
73+
self.path = ""
74+
self.window = window
75+
self.stop_event = stop_event
76+
self.task = task
77+
self.include_browserdata = False
78+
self.result = "error"
79+
6680
class Browser:
6781

6882
def __init__(self, browser_type, name, exec_path, test_path):
@@ -545,5 +559,176 @@ def download_favicon(url):
545559
images = sorted(images, key = lambda x: x[1].height, reverse=True)
546560
return images
547561

562+
def export_config(ei_task_info: ei_task):
563+
# The export process in the background.
564+
try:
565+
# Collect all files
566+
webapps = get_all_desktop_files()
567+
if ei_task_info.include_browserdata:
568+
ice_files = get_all_files(ICE_DIR)
569+
else:
570+
ice_files = get_all_files(ICONS_DIR)
571+
files = webapps + ice_files
572+
total = len(files)
573+
update_interval = 1 if int(total / 100) < 1 else int(total / 100)
574+
575+
# Write the .tar.gz file
576+
with tarfile.open(ei_task_info.path, "w:gz") as tar:
577+
counter = 0
578+
for file in files:
579+
tar.add(file["full_path"], arcname=file["arcname"])
580+
if counter % update_interval == 0:
581+
progress = round(counter / total, 2)
582+
GLib.idle_add(ei_task_info.update_callback, ei_task_info, progress)
583+
584+
if ei_task_info.stop_event.is_set():
585+
# The user aborts the process.
586+
tar.close()
587+
clean_up_export(ei_task_info)
588+
return "cancelled"
589+
counter += 1
590+
591+
ei_task_info.result = "ok"
592+
except Exception as e:
593+
print(e)
594+
ei_task_info.result = "error"
595+
596+
GLib.idle_add(ei_task_info.result_callback, ei_task_info)
597+
598+
def clean_up_export(ei_task_info: ei_task):
599+
# Remove the rest of the exported file when the user aborts the process.
600+
if os.path.exists(ei_task_info.path):
601+
os.remove(ei_task_info.path)
602+
GLib.idle_add(ei_task_info.update_callback, ei_task_info, 1)
603+
604+
def import_config(ei_task_info: ei_task):
605+
# The import process in the background.
606+
try:
607+
# Make a list of the files beforehand so that the file structure can be restored
608+
# if the user aborts the import process.
609+
files_before = get_files_dirs(ICE_DIR) + get_files_dirs(APPS_DIR)
610+
611+
with tarfile.open(ei_task_info.path, "r:gz") as tar:
612+
files = tar.getnames()
613+
total = len(files)
614+
base_dir = os.path.dirname(ICE_DIR)
615+
update_interval = 1 if int(total / 100) < 1 else int(total / 100)
616+
counter = 0
617+
for file in files:
618+
# Exclude the file if it belongs to the browser data.
619+
no_browserdata = ei_task_info.include_browserdata == False
620+
is_ice_dir = file.startswith("ice/")
621+
is_no_icon = not file.startswith("ice/icons")
622+
if not(no_browserdata and is_ice_dir and is_no_icon):
623+
tar.extract(file, base_dir)
624+
625+
if file.startswith("applications/"):
626+
# Redefine the "Exec" section. This is necessary if the username or browser path differs.
627+
path = os.path.join(base_dir, file)
628+
update_exec_path(path)
629+
630+
if counter % update_interval == 0:
631+
progress = round(counter / total, 2)
632+
GLib.idle_add(ei_task_info.update_callback, ei_task_info, progress)
633+
634+
if ei_task_info.stop_event.is_set():
635+
tar.close()
636+
clean_up_import(ei_task_info, files_before)
637+
return "cancelled"
638+
counter += 1
639+
ei_task_info.result = "ok"
640+
except Exception as e:
641+
print(e)
642+
ei_task_info.result = "error"
643+
644+
GLib.idle_add(ei_task_info.result_callback, ei_task_info)
645+
646+
def clean_up_import(ei_task_info: ei_task, files_before):
647+
# Delete all imported files if the import process is aborted.
648+
try:
649+
# Search all new files
650+
files_now = get_files_dirs(ICE_DIR) + get_files_dirs(APPS_DIR)
651+
new_files = list(set(files_now) - set(files_before))
652+
for file in new_files:
653+
if os.path.exists(file):
654+
if os.path.isdir(file):
655+
shutil.rmtree(file)
656+
else:
657+
os.remove(file)
658+
659+
GLib.idle_add(ei_task_info.update_callback, ei_task_info, 1)
660+
except Exception as e:
661+
print(e)
662+
663+
def check_browser_directories_tar(path):
664+
# Check if the archive contains browser data.
665+
try:
666+
with tarfile.open(path, "r:gz") as tar:
667+
for member in tar:
668+
parts = member.name.strip("/").split("/")
669+
if parts[0] == "ice" and parts[1] != "icons":
670+
tar.close()
671+
return True
672+
tar.close()
673+
return False
674+
except:
675+
return False
676+
677+
def get_all_desktop_files():
678+
# Search all web apps and desktop files.
679+
files = []
680+
for filename in os.listdir(APPS_DIR):
681+
if filename.lower().startswith("webapp-") and filename.endswith(".desktop"):
682+
full_path = os.path.join(APPS_DIR, filename)
683+
arcname = os.path.relpath(full_path, os.path.dirname(APPS_DIR))
684+
files.append({"full_path":full_path, "arcname":arcname})
685+
return files
686+
687+
def get_all_files(base_dir):
688+
# List all the files in a directory.
689+
files = []
690+
for root, dirs, filenames in os.walk(base_dir):
691+
for filename in filenames:
692+
full_path = os.path.join(root, filename)
693+
arcname = ""
694+
if base_dir == ICONS_DIR:
695+
arcname += "ice/"
696+
arcname += os.path.relpath(full_path, os.path.dirname(base_dir))
697+
files.append({"full_path":full_path, "arcname":arcname})
698+
return files
699+
700+
def get_files_dirs(base_dir):
701+
# List all the files and subdirectories within a directory.
702+
paths = []
703+
for dirpath, dirnames, filenames in os.walk(base_dir):
704+
paths.append(dirpath)
705+
for name in filenames:
706+
paths.append(os.path.join(dirpath, name))
707+
return paths
708+
709+
def update_exec_path(path):
710+
# This updates the 'exec' section of an imported web application or creates the browser directory for it.
711+
config = configparser.RawConfigParser()
712+
config.optionxform = str
713+
config.read(path)
714+
codename = os.path.basename(path)
715+
codename = codename.replace(".desktop", "")
716+
codename = codename.replace("WebApp-", "")
717+
codename = codename.replace("webapp-", "")
718+
webapp = WebAppLauncher(path, codename)
719+
browsers = WebAppManager.get_supported_browsers()
720+
if "/" in webapp.icon:
721+
# Update Icon Path
722+
iconpath = ICONS_DIR + "/" + os.path.basename(webapp.icon)
723+
config.set("Desktop Entry", "Icon", iconpath)
724+
else:
725+
iconpath = webapp.icon
726+
727+
browser = next((browser for browser in browsers if browser.name == webapp.web_browser), None)
728+
new_exec_line = WebAppManager.get_exec_string(None, browser, webapp.codename, webapp.custom_parameters, iconpath, webapp.isolate_profile, webapp.navbar, webapp.privatewindow, webapp.url)
729+
config.set("Desktop Entry", "Exec", new_exec_line)
730+
with open(path, 'w') as configfile:
731+
config.write(configfile, space_around_delimiters=False)
732+
548733
if __name__ == "__main__":
549734
download_favicon(sys.argv[1])

usr/lib/webapp-manager/webapp-manager.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import subprocess
99
import warnings
10+
import threading
1011

1112
# 2. Related third party imports.
1213
import gi
@@ -21,7 +22,7 @@
2122
from gi.repository import Gtk, Gdk, Gio, XApp, GdkPixbuf
2223

2324
# 3. Local application/library specific imports.
24-
from common import _async, idle, WebAppManager, download_favicon, ICONS_DIR, BROWSER_TYPE_FIREFOX, BROWSER_TYPE_FIREFOX_FLATPAK, BROWSER_TYPE_FIREFOX_SNAP
25+
from common import _async, idle, WebAppManager, download_favicon, BROWSER_TYPE_FIREFOX, BROWSER_TYPE_FIREFOX_FLATPAK, BROWSER_TYPE_FIREFOX_SNAP, export_config, import_config, ei_task, check_browser_directories_tar
2526

2627
setproctitle.setproctitle("webapp-manager")
2728

@@ -124,6 +125,20 @@ def __init__(self, application):
124125
self.window.add_accel_group(accel_group)
125126
menu = self.builder.get_object("main_menu")
126127
item = Gtk.ImageMenuItem()
128+
item.set_image(Gtk.Image.new_from_icon_name("document-send-symbolic", Gtk.IconSize.MENU))
129+
item.set_label(_("Export"))
130+
item.connect("activate", lambda widget: self.open_ei_tool("export"))
131+
key, mod = Gtk.accelerator_parse("<Control><Shift>E")
132+
item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE)
133+
menu.append(item)
134+
item = Gtk.ImageMenuItem()
135+
item.set_image(Gtk.Image.new_from_icon_name("document-open-symbolic", Gtk.IconSize.MENU))
136+
item.set_label(_("Import"))
137+
item.connect("activate", lambda widget: self.open_ei_tool("import"))
138+
key, mod = Gtk.accelerator_parse("<Control><Shift>I")
139+
item.add_accelerator("activate", accel_group, key, mod, Gtk.AccelFlags.VISIBLE)
140+
menu.append(item)
141+
item = Gtk.ImageMenuItem()
127142
item.set_image(
128143
Gtk.Image.new_from_icon_name("preferences-desktop-keyboard-shortcuts-symbolic", Gtk.IconSize.MENU))
129144
item.set_label(_("Keyboard Shortcuts"))
@@ -541,6 +556,125 @@ def load_webapps(self):
541556
self.stack.set_visible_child_name("main_page")
542557
self.headerbar.set_subtitle(_("Run websites as if they were apps"))
543558

559+
# Export and Import feature "ei"
560+
def open_ei_tool(self, action):
561+
# Open the import / export window
562+
gladefile = "/usr/share/webapp-manager/ei_tool.ui"
563+
builder = Gtk.Builder()
564+
builder.set_translation_domain(APP)
565+
builder.add_from_file(gladefile)
566+
window = builder.get_object("window")
567+
# Translate text and prepare widgets
568+
if action == "export":
569+
window.set_title(_("Export Tool"))
570+
else:
571+
window.set_title(_("Import Tool"))
572+
builder.get_object("choose_location_text").set_text(_("Choose a location"))
573+
builder.get_object("include_browserdata").set_label(_("BETA: Include Browser data (Config, Cache, Extensions...)\nIt requires the same browser version on the destination computer\nIt might take some time."))
574+
builder.get_object("no_browser_data").set_text(_("Browser data import not available because \nit is not included in the importet file."))
575+
builder.get_object("no_browser_data").set_visible(False)
576+
builder.get_object("start_button").set_label(_("Start"))
577+
builder.get_object("start_button").connect("clicked", lambda button: self.ei_start_process(button, ei_task_info))
578+
builder.get_object("cancel_button").set_visible(False)
579+
builder.get_object("select_location_button").connect("clicked", lambda widget: self.select_location(ei_task_info))
580+
581+
# Prepare ei_task_info which stores all the values for the import / export
582+
stop_event = threading.Event()
583+
ei_task_info = ei_task(self.show_ei_result, self.update_ei_progress, builder, self, window, stop_event, action)
584+
window.show()
585+
586+
def ei_start_process(self, button, ei_task_info: ei_task):
587+
# Start the import / export process
588+
buffer = ei_task_info.builder.get_object("file_path").get_buffer()
589+
path = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
590+
if path != "":
591+
ei_task_info.path = path
592+
ei_task_info.include_browserdata = ei_task_info.builder.get_object("include_browserdata").get_active()
593+
button.set_sensitive(False)
594+
if ei_task_info.task == "export":
595+
thread = threading.Thread(target=export_config, args=(ei_task_info,))
596+
else:
597+
thread = threading.Thread(target=import_config, args=(ei_task_info,))
598+
thread.start()
599+
ei_task_info.builder.get_object("cancel_button").set_visible(True)
600+
ei_task_info.builder.get_object("cancel_button").connect("clicked", lambda button: self.abort_ei(button, ei_task_info, thread))
601+
602+
603+
def select_location(self, ei_task_info: ei_task):
604+
# Open the file chooser window
605+
if ei_task_info.task == "export":
606+
buttons = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
607+
dialog = Gtk.FileChooserDialog(_("Export Configuration - Please choose a file location"), self.window, Gtk.FileChooserAction.SAVE, buttons)
608+
else:
609+
buttons = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
610+
dialog = Gtk.FileChooserDialog(_("Import Configuration - Please select the file"), self.window, Gtk.FileChooserAction.OPEN, buttons)
611+
612+
filter = Gtk.FileFilter()
613+
filter.set_name(".tar.gz")
614+
filter.add_pattern("*.tar.gz")
615+
dialog.add_filter(filter)
616+
response = dialog.run()
617+
if response == Gtk.ResponseType.OK:
618+
path = dialog.get_filename()
619+
if ei_task_info.task == "export":
620+
path += ".tar.gz"
621+
ei_task_info.builder.get_object("file_path").get_buffer().set_text(path)
622+
623+
# Check if include browser data is available
624+
include_browser_available = True
625+
if ei_task_info.task == "import":
626+
if not check_browser_directories_tar(path):
627+
include_browser_available = False
628+
629+
ei_task_info.builder.get_object("include_browserdata").set_sensitive(include_browser_available)
630+
ei_task_info.builder.get_object("no_browser_data").set_visible(not include_browser_available)
631+
ei_task_info.builder.get_object("include_browserdata").set_active(include_browser_available)
632+
dialog.destroy()
633+
634+
635+
def abort_ei(self, button, ei_task_info:ei_task, thread):
636+
# Abort the export / import process
637+
button.set_sensitive(False)
638+
self.update_ei_progress(ei_task_info, 0)
639+
# The backend function will automatically clean up after the stop flag is triggered.
640+
ei_task_info.stop_event.set()
641+
thread.join()
642+
643+
def update_ei_progress(self, ei_task_info:ei_task, progress):
644+
# Update the progress bar or close the tool window by 100%.
645+
try:
646+
ei_task_info.builder.get_object("progress").set_fraction(progress)
647+
if progress == 1:
648+
ei_task_info.window.destroy()
649+
except:
650+
# The user closed the progress window
651+
pass
652+
653+
654+
def show_ei_result(self, ei_task_info:ei_task):
655+
# Displays a success or failure message when the process is complete.
656+
ei_task_info.window.destroy()
657+
if ei_task_info.result == "ok":
658+
message = _(ei_task_info.task.capitalize() + " completed!")
659+
else:
660+
message = _(ei_task_info.task.capitalize() + " failed!")
661+
662+
if ei_task_info.result == "ok" and ei_task_info.task == "export":
663+
# This dialog box gives users the option to open the containing directory.
664+
dialog = Gtk.Dialog(message, ei_task_info.webAppLauncherSelf.window, None, (_("Open Containing Folder"), 10, Gtk.STOCK_OK, Gtk.ButtonsType.OK))
665+
dialog.get_content_area().add(Gtk.Label(label=_("Configuration has been exported successfully. This is the file location:")+"\n"+ei_task_info.path))
666+
dialog.show_all()
667+
result = dialog.run()
668+
if result == 10:
669+
# Open Containing Folder
670+
print("open folder")
671+
os.system("xdg-open " + os.path.dirname(ei_task_info.path))
672+
else:
673+
dialog = Gtk.MessageDialog(text=message, message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK)
674+
dialog.run()
675+
676+
dialog.destroy()
677+
ei_task_info.webAppLauncherSelf.load_webapps()
544678

545679
if __name__ == "__main__":
546680
application = MyApplication("org.x.webapp-manager", Gio.ApplicationFlags.FLAGS_NONE)

0 commit comments

Comments
 (0)