GUI: Highly improve restore window memory usage

This commit is contained in:
deajan 2024-09-15 10:06:35 +02:00
parent 65d4559bbd
commit 410121e2cf
8 changed files with 166 additions and 47 deletions

View file

@ -7,7 +7,7 @@ __intname__ = "npbackup.gui.core.runner"
__author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2022-2024 NetInvent"
__license__ = "GPL-3.0-only"
__build__ = "2024061101"
__build__ = "2024091501"
from typing import Optional, Callable, Union, List
@ -220,6 +220,9 @@ class NPBackupRunner:
self._verbose = False
self._live_output = False
self._json_output = False
# struct_output is msgspec.Struct instead of json, which is less memory consuming
# struct_output neeeds json_output to be True
self._struct_output = False
self._binary = None
self._no_cache = False
self.restic_runner = None
@ -300,6 +303,17 @@ class NPBackupRunner:
self.write_logs(msg, level="critical", raise_error="ValueError")
self._json_output = value
@property
def struct_output(self):
return self._struct_output
@struct_output.setter
def struct_output(self, value):
if not isinstance(value, bool):
msg = f"Bogus struct_output parameter given: {value}"
self.write_logs(msg, level="critical", raise_error="ValueError")
self._struct_output = value
@property
def stdout(self):
return self._stdout
@ -409,7 +423,7 @@ class NPBackupRunner:
f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info"
)
try:
os.environ['NPBACKUP_EXEC_TIME'] = str(self.exec_time)
os.environ["NPBACKUP_EXEC_TIME"] = str(self.exec_time)
except OSError:
pass
return result
@ -826,6 +840,7 @@ class NPBackupRunner:
self.restic_runner.no_cache = self.no_cache
self.restic_runner.live_output = self.live_output
self.restic_runner.json_output = self.json_output
self.restic_runner.struct_output = self.struct_output
self.restic_runner.stdout = self.stdout
self.restic_runner.stderr = self.stderr
if self.binary:

View file

@ -13,6 +13,7 @@ from typing import List, Optional, Tuple
import sys
import os
import re
import gc
from argparse import ArgumentParser
from pathlib import Path
from logging import getLogger
@ -41,6 +42,8 @@ from resources.customization import (
LOADER_ANIMATION,
FOLDER_ICON,
FILE_ICON,
SYMLINK_ICON,
IRREGULAR_FILE_ICON,
LICENSE_TEXT,
SIMPLEGUI_THEME,
OEM_ICON,
@ -55,6 +58,7 @@ from npbackup.path_helper import CURRENT_DIR
from npbackup.__version__ import version_string
from npbackup.__debug__ import _DEBUG
from npbackup.restic_wrapper import ResticRunner
from npbackup.restic_wrapper import schema
logger = getLogger()
@ -183,39 +187,60 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData:
{'name': 'unsupported.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/unsupported.tcl', 'uid': 0, 'gid': 0, 'size': 10521, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'}
{'name': 'xmfbox.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/xmfbox.tcl', 'uid': 0, 'gid': 0, 'size': 27064, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'}
]
Since v3-rc6, we're actually using a msgspec.Struct represenation which uses dot notation
"""
treedata = sg.TreeData()
count = 0
for entry in ls_result:
# Make sure we drop the prefix '/' so sg.TreeData does not get an empty root
entry["path"] = entry["path"].lstrip("/")
entry.path = entry.path.lstrip("/")
if os.name == "nt":
# On windows, we need to make sure tree keys don't get duplicate because of lower/uppercase
# Shown filenames aren't affected by this
entry["path"] = entry["path"].lower()
parent = os.path.dirname(entry["path"])
entry.path = entry.path.lower()
parent = os.path.dirname(entry.path)
# Make sure we normalize mtime, and remove microseconds
# dateutil.parser.parse is *really* cpu hungry, let's replace it with a dumb alternative
# mtime = dateutil.parser.parse(entry["mtime"]).strftime("%Y-%m-%d %H:%M:%S")
mtime = str(entry["mtime"])[0:19]
if entry["type"] == "dir" and entry["path"] not in treedata.tree_dict:
mtime = entry.mtime.strftime("%Y-%m-%d %H:%M:%S")
name = os.path.basename(entry.path)
if entry.type == schema.LsNodeType.DIR and entry.path not in treedata.tree_dict:
treedata.Insert(
parent=parent,
key=entry["path"],
text=entry["name"],
key=entry.path,
text=name,
values=["", mtime],
icon=FOLDER_ICON,
)
elif entry["type"] == "file":
size = BytesConverter(entry["size"]).human
elif entry.type == schema.LsNodeType.FILE:
size = BytesConverter(entry.size).human
treedata.Insert(
parent=parent,
key=entry["path"],
text=entry["name"],
key=entry.path,
text=name,
values=[size, mtime],
icon=FILE_ICON,
)
elif entry.type == schema.LsNodeType.SYMLINK:
treedata.Insert(
parent=parent,
key=entry.path,
text=name,
values=["", mtime],
icon=SYMLINK_ICON,
)
elif entry.type == schema.LsNodeType.IRREGULAR:
treedata.Insert(
parent=parent,
key=entry.path,
text=name,
values=["", mtime],
icon=IRREGULAR_FILE_ICON,
)
# Since the thread is heavily CPU bound, let's add a minimal
# arbitrary sleep time to let GUI update
# In a 130k entry scenario, using count % 1000 added less than a second on a 25 second run
@ -237,12 +262,13 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
__backend_binary=backend_binary,
)
if not result or not result["result"]:
sg.Popup("main_gui.snapshot_is_empty")
sg.Popup(_t("main_gui.snapshot_is_empty"))
return None, None
# result is {"result": True, "output": [{snapshot_description}, {entry}, {entry}]}
content = result["output"]
# content = result["output"]
# First entry of snapshot list is the snapshot description
snapshot = content.pop(0)
snapshot = result["output"].pop(0)
try:
snap_date = dateutil.parser.parse(snapshot["time"])
except (KeyError, IndexError, TypeError):
@ -261,7 +287,6 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
hostname = "[inconnu]"
backup_id = f"{_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}"
if not backup_id or not snapshot or not short_id:
sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True)
return False
@ -272,7 +297,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
# We get a thread result, hence pylint will complain the thread isn't a tuple
# pylint: disable=E1101 (no-member)
thread = _make_treedata_from_json(content)
thread = _make_treedata_from_json(result["output"])
while not thread.done() and not thread.cancelled():
sg.PopupAnimated(
LOADER_ANIMATION,
@ -283,6 +309,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
)
sg.PopupAnimated(None)
logger.info("Finished creating data tree")
left_col = [
[sg.Text(backup_id)],
[
@ -316,8 +344,11 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
enable_close_attempted_event=True,
)
# Reclaim memory fro thread result
thread = None
# Reclaim memory from thread result
# Note from v3 dev: This doesn't actually improve memory usage
del thread
del result
gc.collect()
while True:
event, values = window.read()
@ -334,6 +365,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
# before closing the window
window["-TREE-"].update(values=sg.TreeData())
window.close()
del window
return True
@ -966,6 +998,7 @@ def _main_gui(viewer_mode: bool):
continue
snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0]
ls_window(repo_config, snapshot_to_see)
gc.collect()
if event == "--FORGET--":
if not full_config:
sg.PopupError(_t("main_gui.no_config"), keep_on_top=True)

View file

@ -127,6 +127,10 @@ def gui_thread_runner(
# We'll always use json output in GUI mode
runner.json_output = True
# in GUI mode, we'll use struct output instead of json whenever it's possible
# as of v3, this only is needed for ls operations
runner.struct_output = True
# So we don't always init repo_config, since runner.group_runner would do that itself
if __repo_config:
runner.repo_config = __repo_config

View file

@ -7,8 +7,8 @@ __intname__ = "npbackup.restic_wrapper"
__author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2022-2024 NetInvent"
__license__ = "GPL-3.0-only"
__build__ = "2024090602"
__version__ = "2.2.4"
__build__ = "2024091501"
__version__ = "2.3.0"
from typing import Tuple, List, Optional, Callable, Union
@ -16,7 +16,7 @@ import os
import sys
from logging import getLogger
import re
import json
import msgspec
from datetime import datetime, timezone
import dateutil.parser
import queue
@ -26,7 +26,7 @@ from ofunctions.misc import BytesConverter, fn_name
from npbackup.__debug__ import _DEBUG
from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL
from npbackup.path_helper import CURRENT_DIR
from npbackup.restic_wrapper import schema
logger = getLogger()
@ -48,6 +48,7 @@ class ResticRunner:
self._dry_run = False
self._no_cache = False
self._json_output = False
self._struct_output = False
self.backup_result_content = None
@ -222,6 +223,17 @@ class ResticRunner:
else:
raise ValueError("Bogus json_output value given")
@property
def struct_output(self) -> bool:
return self._struct_output
@struct_output.setter
def struct_output(self, value: bool):
if isinstance(value, bool):
self._struct_output = value
else:
raise ValueError("Bogus struct_output value given")
@property
def ignore_cloud_files(self) -> bool:
return self._ignore_cloud_files
@ -636,6 +648,10 @@ class ResticRunner:
return self.convert_to_json_output(False, output=msg)
else:
# pylint: disable=E1101 (no-member)
output = output.replace(
self.repository,
self.repository.split(":")[0] + ":_(o_O)_hidden_by_npbackup",
)
msg = f"Backend is not ready to perform operation {fn.__name__}. Repo maybe inaccessible or not initialized. You can try to run a backup to initialize the repository:\n{output}." # pylint: disable=E1101 (no-member)
return self.convert_to_json_output(False, msg=msg)
# pylint: disable=E1102 (not-callable)
@ -658,31 +674,40 @@ class ResticRunner:
js = {
"result": result,
"operation": operation,
"extended_info": None,
"args": kwargs,
"output": None,
"output": [],
}
if result:
if output:
if isinstance(output, str):
output = list(filter(None, output.split("\n")))
else:
output = [str(output)]
if len(output) > 1:
output_is_list = True
js["output"] = []
else:
output_is_list = False
for line in output:
if output_is_list:
try:
js["output"].append(json.loads(line))
except json.decoder.JSONDecodeError:
js["output"].append(line)
else:
try:
js["output"] = json.loads(line)
except json.decoder.JSONDecodeError:
js["output"] = {"data": line}
decoder = msgspec.json.Decoder()
ls_decoder = msgspec.json.Decoder(schema.LsNode)
is_first_line = True
for line in output.split("\n"):
if not line:
continue
try:
if (
not is_first_line
and operation == "ls"
and self.struct_output
):
js["output"].append(ls_decoder.decode(line))
else:
js["output"].append(decoder.decode(line))
is_first_line = False
except msgspec.DecodeError as exc:
msg = f"JSON decode error: {exc} on content '{line}'"
self.write_logs(msg, level="error")
js["extended_info"] = msg
js["output"].append({"data": line})
js["result"] = False
# If we only have one output, we don't need a list
if len(js["output"]) == 1:
js["output"] = js["output"][0]
if msg:
self.write_logs(msg, level="info")
else:
@ -691,8 +716,11 @@ class ResticRunner:
self.write_logs(msg, level="error")
if output:
try:
js["output"] = json.loads(output)
except json.decoder.JSONDecodeError:
js["output"] = msgspec.json.decode(output)
except msgspec.DecodeError:
msg = f"JSON decode error: {exc} on output '{output}'"
self.write_logs(msg, level="error")
js["extended_info"] = msg
js["output"] = {"data": output}
return js

View file

@ -0,0 +1,37 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# This file is part of npbackup
__intname__ = "npbackup.restic_wrapper.schema"
__author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2024 NetInvent"
__license__ = "GPL-3.0-only"
__description__ = "Restic json output schemas"
from typing import Optional
from enum import StrEnum
from datetime import datetime
from msgspec import Struct
class LsNodeType(StrEnum):
FILE = "file"
DIR = "dir"
SYMLINK = "symlink"
IRREGULAR = "irregular"
class LsNode(Struct, omit_defaults=True):
"""
restic ls outputs lines of
{"name": "b458b848.2024-04-28-13h07.gz", "type": "file", "path": "/path/b458b848.2024-04-28-13h07.gz", "uid": 0, "gid": 0, "size": 82638431, "mode": 438, "permissions": "-rw-rw-rw-", "mtime": "2024-04-29T10:32:18+02:00", "atime": "2024-04-29T10:32:18+02:00", "ctime": "2024-04-29T10:32:18+02:00", "message_type": "node", "struct_type": "node"}
# In order to save some memory in GUI, let's drop unused data
"""
# name: str # We don't need name, we have path from which we extract name, which is more memory efficient
type: LsNodeType
path: str
mtime: datetime
size: Optional[int] = None

View file

@ -50,6 +50,8 @@ OEM_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAAXNSR0IArs4c6QAAAAR
FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII="
INHERITED_FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJCSURBVDhPxVJNaBNhEH3bJPSHtCWtpRZbU4shJQoigi2IOfTQ2F5EUUEP2oNa0N5E8eBJUEHUg0IxGPAg6EGKeigo9CBSbEuVLNFkIWot1KYxzV/TNt1sdvdzvk3MehARPPhgdub7dt7bN8PiXyGUM0aO2HosVRjgtaywRf9z9REv+flPqAgk3vqUlu7TNmgZFDMi7o8GxqR5/TV16AJvq3QCs5IWmpX0KSq1ynV6xsccLR6wVBRyZg5yYRUqK0DQFDBdIb5WEqkCxmcQGbqh+Oj0rSIQG+9g9c17YW31orqxC5PTYbyceI9Pc0twuzpw6ngftjvrgeIC4pEXelvf5DGijZEecP6Qdahu6wnYPedQ0+TG9duP4R28DHdXAy6c2Y12x3fs6B0GS9wDlgNgcoTTavnDEBAYOhvbewE1CzEYxJVrTzA/PYKTgwX0uD5j+LAGWdxD46yAsSLAOKsEQ8CAmqRIIfwxhH7vFjg3LdGKVhCOZihyECOrxKM9QKcwFazlTLMt07scErEI2ppJV5fx6k0MNx98RTqnwLnZimd3W4mrUjMXKcF0QMth+Q/o3maBKGWpcQP9++yYeOjG2aMO6EynUbl97sJ0YArIUbKcw8B+O6ptDLcCX4iwYThZW1eoQaf5KRsiv3OgpomQpw+s4+mdTkwFsxA877DzoISro0nscllIg5MpfhmhsoPkYrxcCagji/5LtfBfrKH5i2hqoGuynoqTIyLn10yBnz+SneJAOf8N+BKCFCHj9B8B/ACq5vDyyzK0kwAAAABJRU5ErkJggg=="
FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC"
SYMLINK_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAwUExURf///0lJSSgoKNDQ0AAAAMfHxxoaGsTExNfX1/b29hcXFy8vL+zs7MHBwSsrK+jo6JAyYacAAAAJcEhZcwAACxEAAAsRAX9kX5EAAABXSURBVBjTY8AChJSUlAxADBUXF5cAMMOtomMCmLFkDwNnMIhxz/sAhxOI0f9kD4TR8M/7BITB/2Q7iGE6geGfG4jBwNnRuwTMYAWaCGYwA+1QxLCZgQEAN90WKol5c8kAAAAASUVORK5CYII="
IRREGULAR_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAwUExURQAAABISEjg4OEBAQF5eXmdnZ2lpaXl5eYODg46OjpWVlaWlpbGxsdPT0/X19f///y10hqsAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAA1SURBVBjTY/gPBdgZv/KgjI/cUMbPOAjjR3kthPGZgQcq9YUw49citrVgxgcGBi5MS///BwC5AXGMCId5ZAAAAABJRU5ErkJggg=="
INHERITED_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJFSURBVDhPjVNNaBNREP6y2cRo29QfGmJNayBBWxWr+I/gP9VYxFLUnITVFgqCF8GLFyuKeND6g/UkSA8qBC9KDxYVUQQx1otNQ2q1VAWtUcMWQlvT7K4zb7NpdAv6wbe8mfdm5pt5bx0g7Gpp6yCchsHWX+ATBX/i3cjAqWOHDmS+fxm0vBJ/DN1AwFeByVweanYcP8ey01Sz+JYZQ7lHwtHmTcvPXO7umVe1sJ7CRHGZP7qhwyXLmOstI4v5JyZ/5eCg4y5ZQnt0R2gqf+vBCWXPdtr6XFQg0QGn0zEjZWKalPR/GEXqYxqrVywNUdhmjhUKNF2HRBlkp8gn8Cb+Ei+ePcankWHUBkPYu/8gKfQin9eEYoKHPyJCpwROSuCiBMybXZ1Qok0Ih5egrf04AoEaRPdtRcUcDyrLZ0PTRAIBU4GmCQXcYzLxFlcvnsPzeAKLArXi0Jp163FYaRVrrskFLUwroClx9eH3KWzZthPBxUHTHkoJDib7iwp1XRPBDPMWSAG34HZJUDM/4Pf7xfrpk0foutYJVVVJTQ26b8dEUGkLQoE1RM5eV1eP5IBZrbFxN+739EI50kqXbpQomKEFawaRSASz3G7cuH5F2MyJiXG6ZnOfyTOzUFBgtsDXyLxzN4a++Ct4y9zYsHYVLpw/i5UNDcX9UgUCyzY2dVBfRm5Ks/HraNrmiz18zf+BwrGFFgx6qvzqJBt9viqbzz4Deln81qmL/2JpAjKBBdWhyPzq8ElaVhJz7PsXhvp6LwG49xvFxfylO2UHaAAAAABJRU5ErkJggg=="
TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAEtSURBVDhPjZNdTsJAFIUPbalQUSEQNMQ16AJcga++uBJe9AE2wE5civvwzUDQhIClnjMz5Wc6gd7km85M7px7ettpgPH4Ukxfp5ig0MoLZZT7JtvG20PDrCINxRboD4HNGvj9AZaLA+bA/Hs/L2HsBbaskCTA5TXQG1Ds9phuHxjeAVddi4t7DUYAdCBDURwmJgtWzTKLiycN1oEEOAsdFs3Uutv8AasV0GrrFFoadgIRZwmTQ6QXQJuVRdZhfq5TNqwANyQQsw/nkBs1vQwjoI2IPTAVmeQ78KkKOAdJk0hAzxPk/ivkronqdh3CryAHql6DahOdQOgThgj2QD9SyG4IFSxj5+DUn+hTdVDwMlBAInU4FOCSF2T0/twZTcac3hDeyfPx9ZnOAHz8A2r8W8iBwGS0AAAAAElFTkSuQmCC"
INHERITED_TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAIKSURBVDhPjVPPS1RRFP7mzTj425GXM5MNjpHgQNKEP3AhWEE0zCKCNoKbapO7IETBH6BBtYiZTeqfIC5UcCGooItoEQxBRU0tNBgYUBLHRnQQtDfPc857b3w1E/XBdznnvnO/+91z33WAEL6vT/ZNYgI6Z3+AK6x5qTYwes0hmcKDngdUL3B6AuSOgMMDG7NAdv88tkg4F8jTDi4XUFUL1F8gMd/v9KiA1w/UeAyaCPAgAiAHbEhxlqaTeEC7VlYaNNHDA+1L61mApLjYwvfEW3x7s4q91CYamlvRfu8BOWyB9gsor5CSch6MI5CAQpGLBJjrMy8w1deLiy2tuD0wCDUQQPxuGyrYQTXVa7zKgOGAJljASVn6y0csx8bxPJGihUEputLVjRsPByRmk9x0C+KAJxTqAe/+YyuJqzcj8AWDRr6ZFG5//VBwaBcQB7rpwFVG17i/C4/fL/HnjTWsvH6FXPYnuWnCk9klWaTZjiAONLOJ3O1LoRDSyU8SX78TwcjyBm49ekxVuswxSx+BHZCfjmgUZW43VqdjkjNPj4/kmq3c3kQRsG7Buvenc/PYSrxDf5UDw51tWHz5DM3hcOF7yR7wDmyP4bvchKGFRYkPMxnUqPQr2sAbWig4+NufWOdVi+aKe6DTYyABFvkf2gUopQfSOBatbpwYorCOSG/y39h5744DWDgDprSgrrE6IGUAAAAASUVORK5CYII="

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

BIN
resources/symlink_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B