Skip to content

Commit 760bcf3

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 4c29d55 commit 760bcf3

File tree

4 files changed

+507
-2
lines changed

4 files changed

+507
-2
lines changed

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.
@@ -64,6 +65,19 @@ def wrapper(*args):
6465
ICONS_DIR = os.path.join(ICE_DIR, "icons")
6566
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, BROWSER_TYPE_ZEN_FLATPAK = range(10)
6667

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

6983
def __init__(self, browser_type, name, exec_path, test_path):
@@ -551,5 +565,176 @@ def download_favicon(url):
551565
images = sorted(images, key = lambda x: x[1].height, reverse=True)
552566
return images
553567

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

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

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

0 commit comments

Comments
 (0)