source: lliurex-store/trunk/fuentes/python3-lliurex-store.install/usr/share/lliurexstore/plugins/appImageManager.py @ 7118

Last change on this file since 7118 was 7118, checked in by Juanma, 3 years ago

WIP on bundles support

File size: 19.7 KB
Line 
1#The name of the main class must match the file name in lowercase
2import re
3import urllib
4from urllib.request import Request
5from urllib.request import urlretrieve
6import shutil
7import json
8import os
9import sys
10import threading
11import queue
12import time
13import random
14import gi
15from gi.repository import Gio
16gi.require_version('AppStreamGlib', '1.0')
17from gi.repository import AppStreamGlib as appstream
18from bs4 import BeautifulSoup
19#from subprocess import call
20
21class appimagemanager:
22        def __init__(self):
23                self.dbg=True
24                self.progress=0
25                self.partial_progress=0
26                self.plugin_actions={'install':'appimage','remove':'appimage','pkginfo':'appimage','load':'appimage'}
27                self.result={}
28                self.result['data']={}
29                self.result['status']={}
30                self.conf_dir=os.getenv("HOME")+"/.cache/lliurex-store"
31                self.icons_dir=self.conf_dir+"/icons"
32                self.bundles_dir=self.conf_dir+"/bundles"
33                self.bundle_types=['appimg']
34                self.appimage_dir=os.getenv("HOME")+"/.lliurex-store/appimg"
35                #To get the description of an app we must go to a specific url defined in url_info.
36                #$(appname) we'll be replaced with the appname so the url matches the right one.
37                #If other site has other url naming convention it'll be mandatory to define it with the appropiate replacements
38                self.repos={'appimagehub':{'type':'json','url':'https://appimage.github.io/feed.json','url_info':''}}
39                #Appimges not stored in a repo must be listed in this file, providing the download url and the info url (if there's any)
40                self.external_appimages="/usr/share/lliurex-store/files/external_appimages.json"
41                #Queue pool
42                self.queue=queue.Queue()
43                self.disabled=False
44        #def __init__
45
46        def set_debug(self,dbg=True):
47                self.dbg=dbg
48                self._debug ("Debug enabled")
49        #def set_debug
50
51        def _debug(self,msg=''):
52                if self.dbg:
53                        print ('DEBUG appimage: %s'%msg)
54        #def debug
55
56        def register(self):
57                return(self.plugin_actions)
58
59        def enable(self,state=False):
60                self.disable=state
61
62        def execute_action(self,action,applist=None,store=None):
63                if store:
64                        self.store=store
65                else:
66                        self.store=appstream.Store()
67                self.progress=0
68                self.result['status']={'status':-1,'msg':''}
69                self.result['data']=''
70                dataList=[]
71                if self.disabled:
72                        self._set_status(9)
73                        self.result['data']=self.store
74                else:
75                        self._chk_installDir()
76                        if action=='load':
77                                self.result['data']=self._load_appimage_store(self.store)
78                        else:
79                                for app_info in applist:
80                                        self.partial_progress=0
81                                        if action=='install':
82                                                dataList.append(self._install_appimage(app_info))
83                                        if action=='remove':
84                                                dataList.append(self._remove_appimage(app_info))
85                                        if action=='pkginfo':
86                                                dataList.append(self._get_info(app_info))
87                                        self.progress+=int(self.partial_progress/len(applist))
88                                self.result['data']=list(dataList)
89                self.progress=100
90                return(self.result)
91
92        def _set_status(self,status,msg=''):
93                self.result['status']={'status':status,'msg':msg}
94        #def _set_status
95
96        def _callback(self,partial_size=0,total_size=0):
97                limit=99
98                if partial_size!=0 and total_size!=0:
99                        inc=round(partial_size/total_size,2)*100
100                        self.progress=inc
101                else:
102                        inc=1
103                        margin=limit-self.progress
104                        inc=round(margin/limit,3)
105                        self.progress=(self.progress+inc)
106                if (self.progress>limit):
107                        self.progress=limit
108
109        def _chk_installDir(self):
110                msg_status=True
111                if not os.path.isdir(self.appimage_dir):
112                        try:
113                                os.makedirs(self.appimage_dir)
114                        except:
115                                msg_status=False
116                return msg_status                               
117
118        def _install_appimage(self,app_info):
119                app_info=self._get_info(app_info)
120                self._debug("Installing %s"%app_info)
121                if app_info['state']=='installed':
122                        self._set_status(4)
123                else:
124                                #                       appimage_url=self.repository_url+'/'+app_info['package']
125                        appimage_url=app_info['homepage']+'/'+app_info['package']
126                        self._debug("Downloading "+appimage_url)
127                        dest_path=self.appimage_dir+'/'+app_info['package']
128                        if appimage_url:
129                                try:
130                                        req=Request(appimage_url, headers={'User-Agent':'Mozilla/5.0'})
131                                        with urllib.request.urlopen(req) as response, open(dest_path, 'wb') as out_file:
132                                                bf=16*1024
133                                                acumbf=0
134#                                               print("Response: %s"%response.info())
135                                                app_size=int(response.info()['Content-Length'])
136#                                               print("APP SIZE: %s"%app_size)
137                                                while True:
138                                                        if acumbf>=app_size:
139                                                            break
140                                                        shutil.copyfileobj(response, out_file,bf)
141                                                        acumbf=acumbf+bf
142                                                        self._callback(acumbf,app_size)
143                                        st = os.stat(dest_path)
144                                        os.chmod(dest_path, st.st_mode | 0o111)
145                                        self._set_status(0)
146                                except Exception as e:
147                                        print(e)
148                                        self._set_status(5)
149                        else:
150                                self._set_status(12)
151                return app_info
152        #def _install_appimage
153
154        def _remove_appimage(self,app_info):
155                self._debug("Removing "+app_info['package'])
156                if os.path.isfile(self.appimage_dir+'/'+app_info['package']):
157                        try:
158                                call([self.appimage_dir+"/"+app_info['package'], "--remove-appimage-desktop-integration"])
159                        except:
160                                pass
161                        try:
162                                os.remove(self.appimage_dir+"/"+app_info['package'])
163                                self._set_status(0)
164                        except:
165                                self._set_status(6)
166                return(app_info)
167        #def _remove_appimage
168
169        def _get_info(self,app_info):
170                app_info['state']='available'
171                if os.path.isfile(self.appimage_dir+'/'+app_info['package']):
172                        app_info['state']='installed'
173                #Get size
174                appimage_url=app_info['homepage']+'/'+app_info['package']
175                dest_path=self.appimage_dir+'/'+app_info['package']
176                if appimage_url:
177                        try:
178                                with urllib.request.urlopen(appimage_url) as response:
179                                        app_info['size']=(response.info()['Content-Length'])
180                        except:
181                                app_info['size']=0
182                self._set_status(0)
183                self.partial_progress=100
184                return(app_info)
185        #def _get_info
186
187        def _load_appimage_store(self,store):
188                self._get_bundles_catalogue()
189                if os.path.exists(self.bundles_dir):
190                        for bundle_type in self.bundle_types:
191                                self._debug("Loading %s catalog"%bundle_type)
192                                store=self._generic_file_load(self.bundles_dir+'/'+bundle_type,store)
193                return(store)
194        #def load_bundles_catalog(self)
195       
196        def _generic_file_load(self,target_path,store):
197                icon_path='/usr/share/icons/hicolor/128x128'
198                if not os.path.isdir(target_path):
199                        os.makedirs(target_path)
200                files=os.listdir(target_path)
201                for target_file in os.listdir(target_path):
202                        if target_file.endswith('appdata.xml'):
203                                store_path=Gio.File.new_for_path(target_path+'/'+target_file)
204                                self._debug("Adding file "+target_path+'/'+target_file)
205                                try:
206                                        store.from_file(store_path,icon_path,None)
207                                except Exception as e:
208                                        self._debug("Couldn't add file "+target_file+" to store")
209                                        self._debug("Reason: "+str(e))
210                return(store)
211        #def _generic_file_load
212
213        def _get_bundles_catalogue(self):
214                applist=[]
215                appdict={}
216                all_apps=[]
217                outdir=self.bundles_dir+'/appimg/'
218                #Load repos
219                for repo_name,repo_info in self.repos.items():
220                        if not os.path.isdir(self.bundles_dir):
221                                try:
222                                        os.makedirs(self.bundles_dir)
223                                except:
224                                        self._debug("appImage catalogue could not be fetched: Permission denied")
225                        self._debug("Fetching repo %s"%repo_info['url'])
226                        if repo_info['type']=='json':
227                                applist=self._process_appimage_json(self._fetch_repo(repo_info['url']),repo_name)
228
229                        self._debug("Fetched repo "+repo_info['url'])
230                        self._th_generate_xml_catalog(applist,outdir,repo_info['url_info'],repo_info['url'],repo_name)
231                        all_apps.extend(applist)
232                #Load external apps
233                for app_name,app_info in self._get_external_appimages().items():
234                        if os.path.isdir(self.bundles_dir):
235                                appinfo=self._init_appinfo()
236                                appinfo['name']=app_info['url'].split('/')[-1]
237                                appinfo['package']=app_info['url'].split('/')[-1]
238                                appinfo['homepage']='/'.join(app_info['url'].split('/')[0:-1])
239                                self._debug("Fetching external appimage %s"%app_info['url'])
240                                appinfo['bundle']='appimage'
241                                applist=[appinfo]
242                                self._th_generate_xml_catalog(applist,outdir,app_info['url_info'],app_info['url'],app_name)
243                                self._debug("Fetched appimage "+app_info['url'])
244                                all_apps.extend(applist)
245                        else:
246                                self._debug("External appImage could not be fetched: Permission denied")
247                self._debug("Removing old entries...")
248#               self._clean_bundle_catalogue(all_apps,outdir)
249                return(True)
250        #def _get_bundles_catalogue
251       
252        def _fetch_repo(self,repo):
253                req=Request(repo, headers={'User-Agent':'Mozilla/5.0'})
254                with urllib.request.urlopen(req) as f:
255                        content=(f.read().decode('utf-8'))
256               
257                return(content)
258        #def _fetch_repo
259       
260        def _get_external_appimages(self):
261                external_appimages={}
262                if os.path.isfile(self.external_appimages):
263                        try:
264                                with open(self.external_appimages) as appimages:
265                                        external_appimages=json.load(appimages)
266                        except:
267                                self._debug("Can't load %s"%self.external_appimages)
268                self._debug(external_appimages)
269                return external_appimages
270        #def _get_external_appimages
271       
272        def _process_appimage_json(self,data,repo_name):
273                maxconnections = 10
274                semaphore = threading.BoundedSemaphore(value=maxconnections)
275                applist=[]
276                json_data=json.loads(data)
277                if 'items' in json_data.keys():
278                        for appimage in json_data['items']:
279                                appinfo=self._th_process_appimage(appimage,semaphore)
280                                if appinfo:
281                                        applist.append(appinfo)
282                                        #                               th=threading.Thread(target=self._th_process_appimage, args = (appimage,semaphore))
283#                               th.start()
284
285#               while threading.active_count()>1:
286#                       print("%s"%threading.active_count())
287#                       time.sleep(0.1)
288
289#               while not self.queue.empty():
290#                       applist.append(self.queue.get())
291#                       self._debug("Added %s"%applist[-1])
292                self._debug("JSON: %s"%applist)
293                return (applist)
294        #_process_appimage_json
295
296        def _th_process_appimage(self,appimage,semaphore):
297                appinfo=None
298#               semaphore.acquire()
299#               lock=threading.Lock()
300                releases=[]
301                if 'links' in appimage.keys():
302                        if appimage['links']:
303                                appinfo=self.load_json_appinfo(appimage)
304#Deprecated. appImage releases will be load on the info stage
305#                       releases=self._get_releases_from_json(appimage)
306#                       if releases:
307#                               appinfo['releases']=releases
308#                               for release in releases:
309#                                       #Release has the direct download url
310#                                       tmp_release=release.split('/')
311#                                       tmp_appinfo=appinfo.copy()
312#                                       rel_number=tmp_release[-2]
313#                                       rel_name=tmp_release[-1].lower().replace('.appimage','')
314#                                       self._debug("Release: %s"%release)
315#                                       tmp_appinfo['name']=rel_name
316#                                       tmp_appinfo['package']=tmp_release[-1]
317#                                       tmp_appinfo['homepage']='/'.join(tmp_release[0:-1])
318#                                       self.queue.put(tmp_appinfo)
319#Threading disabled
320#                               self.queue.put(appinfo)
321                return(appinfo)
322        #def _th_process_appimage
323
324        def load_json_appinfo(self,appimage):
325                appinfo=self._init_appinfo()
326                appinfo['name']=appimage['name']
327                appinfo['package']=appimage['name']
328                if 'license' in appimage.keys():
329                        appinfo['license']=appimage['license']
330                appinfo['summary']=''
331                if 'description' in appimage.keys():
332                        appinfo['description']=appimage['description']
333                if 'categories' in appimage.keys():
334                        appinfo['categories']=appimage['categories']
335                if 'icon' in appimage.keys():
336                        appinfo['icon']=appimage['icon']
337                if 'screenshots' in appimage.keys():
338                        appinfo['thumbnails']=appimage['screenshots']
339                if 'links' in appimage.keys():
340                        if appimage['links']:
341                                for link in appimage['links']:
342                                        if 'url' in link.keys() and link['type']=='Download':
343                                                appinfo['homepage']=link['url']
344                elif 'authors' in appimage.keys():
345                        if appimage['authors']:
346                                for author in appimage['authors']:
347                                        if 'url' in author.keys():
348                                                appinfo['homepage']=author['url']
349                appinfo['bundle']='appimage'
350                return appinfo
351        #def load_json_appinfo
352
353        def _get_releases_from_json(self,appimage):
354                releases=[]
355                if appimage['links']:
356                        for link in appimage['links']:
357                                if 'type' in link.keys():
358                                        if link['type']=='Download':
359                                                self._debug("Info url: %s"%link['url'])
360                                                try:
361                                                        sw_git=False
362                                                        with urllib.request.urlopen(link['url']) as f:
363                                                                if 'github' in link['url']:
364                                                                        sw_git=True
365                                                                content=(f.read().decode('utf-8'))
366                                                                soup=BeautifulSoup(content,"html.parser")
367                                                                package_a=soup.findAll('a', attrs={ "href" : re.compile(r'.*[aA]pp[iI]mage$')})
368                                                                for package_data in package_a:
369                                                                        package_name=package_data.findAll('strong', attrs={ "class" : "pl-1"})
370                                                                        package_link=package_data['href']
371                                                                        if sw_git:
372                                                                                package_link="https://github.com"+package_link
373                                                                                releases.append(package_link)
374                                                                                self._debug("Link: %s"%package_link)
375                                                except Exception as e:
376                                                        print(e)
377                return releases
378        #def _get_releases_from_json
379
380        def _th_generate_xml_catalog(self,applist,outdir,info_url,repo,repo_name):
381                maxconnections = 10
382                semaphore = threading.BoundedSemaphore(value=maxconnections)
383                random_applist = list(applist)
384                random.shuffle(random_applist)
385                for app in applist:
386                        th=threading.Thread(target=self._th_write_xml, args = (app,outdir,info_url,repo,repo_name,semaphore))
387                        th.start()
388        #def _th_generate_xml_catalog
389
390        def _th_write_xml(self,appinfo,outdir,info_url,repo,repo_name,semaphore):
391                semaphore.acquire()
392                lock=threading.Lock()
393                self._debug("Populating %s"%appinfo)
394                package=appinfo['name'].lower().replace(".appimage","")
395                if len(package)>40:
396                        tmp_package=package.split('-')
397                        tam=0
398                        pos=1
399                        index=0
400                        banned=['linux32','linux64','i386','x86_64','ia32','amd64']
401                        for tmp_name in tmp_package:
402                                if (tam<len(tmp_name) and pos<index) and tmp_name not in banned:
403                                        tam=len(tmp_name)
404                                        pos=index
405                                index+=1
406                        print("Removed: %s"%tmp_package[pos])
407                        tmp_package[pos]=''
408                        package='-'.join(tmp_package)
409                        package=package.replace("--","-")
410                        package=package.replace("-.",".")
411
412                filename=outdir+package.lower().replace('appimage',"appdata")+".xml"
413                self._debug("checking if we need to download "+filename)
414                if not os.path.isfile(filename):
415                        repo_info={'info_url':info_url,'repo':repo,repo_name:'repo_name'}
416                        self._write_xml_file(filename,appinfo,repo_info,lock)
417                semaphore.release()
418        #def _th_write_xml
419
420        def _write_xml_file(self,filename,appinfo,repo_info,lock):
421                        name=appinfo['name'].lower().replace(".appimage","")
422                        self._debug("Generating %s xml"%appinfo['package'])
423                        f=open(filename,'w')
424                        f.write('<?xml version="1.0" encoding="UTF-8"?>'+"\n")
425                        f.write("<components version=\"0.10\">\n")
426                        f.write("<component  type=\"desktop-application\">\n")
427                        f.write("  <id>%s</id>\n"%appinfo['package'].lower())
428                        f.write("  <pkgname>%s</pkgname>\n"%appinfo['package'])
429                        f.write("  <name>%s</name>\n"%name)
430                        f.write("  <metadata_license>CC0-1.0</metadata_license>\n")
431                        f.write("  <provides><binary>%s</binary></provides>\n"%appinfo['package'])
432                        if 'releases' in appinfo.keys():
433                                f.write("  <releases>\n")
434                                for release in appinfo['releases']:
435                                        tmp_release=release.split('/')
436                                        rel_number='/'.join(tmp_release[-2:])
437                                        f.write("    <release version=\"%s\" urgency=\"medium\">"%rel_number)
438                                        f.write("</release>\n")
439                                f.write("  </releases>\n")
440                        f.write("  <launchable type=\"desktop-id\">%s.desktop</launchable>\n"%name)
441                        if appinfo['description']=='':
442                                with lock:
443                                        try:
444                                                if appinfo['name'] in self.descriptions_dict.keys():
445                                                        (description,icon)=self.descriptions_dict[appinfo['name']]
446                                                else:
447                                                        (description,icon)=self._get_description_icon(appinfo['name'],repo_info)
448                                                        self.descriptions_dict.update({appinfo['name']:[description,icon]})
449                                        except:
450                                                description=''
451                                                icon=''
452                        else:
453                                description=appinfo['description']
454                        summary=' '.join(list(description.split(' ')[:8]))
455                        if len(description.split(' '))>8:
456                                summary+="... "
457                        description="This is an AppImage bundle of app %s. It hasn't been tested by our developers and comes from a 3rd party dev team. Please use it carefully.\n%s"%(name,description)
458                        if summary=='':
459                                summary=' '.join(list(description.split(' ')[:8]))
460                        f.write("  <description><p></p><p>%s</p></description>\n"%description)
461                        f.write("  <summary>%s</summary>\n"%summary)
462#                       f.write("  <icon type=\"local\">%s</icon>\n"%appinfo['icon'])
463                        f.write("<icon type=\"cached\">"+name+"_"+name+".png</icon>\n")
464                        f.write("  <url type=\"homepage\">%s</url>\n"%appinfo['homepage'])
465                        f.write("  <bundle type=\"appimage\">%s</bundle>\n"%appinfo['name'])
466                        f.write("  <keywords>\n")
467                        keywords=name.split("-")
468                        banned_keywords=["linux","x86_64","i386","ia32","amd64"]
469                        for keyword in keywords:
470                                #Better than isalpha method for this purpose
471                                if keyword.isidentifier() and keyword not in banned_keywords:
472                                        f.write("       <keyword>%s</keyword>\n"%keyword)
473                        f.write("       <keyword>appimage</keyword>\n")
474                        f.write("  </keywords>\n")
475                        f.write("  <categories>\n")
476                        f.write("       <category>AppImage</category>\n")
477                        if 'categories' in appinfo.keys():
478                                for category in appinfo['categories']:
479                                        f.write("       <category>%s</category>\n"%category)
480                        f.write("  </categories>\n")
481                        f.write("</component>\n")
482                        f.write("</components>\n")
483                        f.close()
484        #def _write_xml_file
485
486        def _get_description_icon(self,app_name,info_url,repo_name):
487                desc=''
488                icon=''
489                if info_url:
490                        if '$(appname)' in info_url:
491                                info_url=info_url.replace('$(appname)',app_name)
492                        self._debug("Getting description from repo/app %s - %s "%(repo_name,info_url))
493                        try:
494                                with urllib.request.urlopen(info_url) as f:
495                                        if repo_name=='probono':
496                                                content=(f.read().decode('utf-8'))
497                                                soup=BeautifulSoup(content,"html.parser")
498                                                description_div=soup.findAll('div', attrs={ "class" : "description-text"})
499                                                icon_div=soup.findAll('div', attrs={ "class" : "avatar-icon avatar-large description-icon "})
500                                if len(description_div)>0:
501                                        desc=description_div[0].text
502                                        desc=desc.replace(':','.')
503                                        desc=desc.replace('&','&amp;')
504                                if len(icon_div)>0:
505                                        icon_str=str(icon_div[0])
506                                        icon=icon_str.split(' ')[9]
507                                        icon=icon.lstrip('url(')
508                                        if icon.startswith('http'):
509                                                icon=icon.rstrip(');"></div>')
510                                                icon=self._download_file(icon,app_name)
511                                        print("Icon: %s"%icon)
512                        except Exception as e:
513                                print("Can't get description from "+info_url)
514                                print(str(e))
515                                pass
516                return([desc,icon])
517        #def _get_description
518
519        def _clean_bundle_catalogue(self,applist,outdir):
520                xml_files_list=[]
521                applist=[item.lower() for item in applist]
522                for xml_file in os.listdir(outdir):
523                        if xml_file.endswith('appdata.xml'):
524                                xml_files_list.append(xml_file.lower().replace('appdata.xml','appimage'))
525       
526                if xml_files_list:
527                        xml_discard_list=list(set(xml_files_list).difference(applist))
528                        for discarded_file in xml_discard_list:
529                                os.remove(outdir+'/'+discarded_file.replace('appimage','appdata.xml'))
530        #def _clean_bunlde_catalogue
531
532        def _download_file(self,url,app_name):
533#               target_file=self.icons_folder+'/'+app_name+".png"
534                target_file=self.icons_dir+'/'+app_name+".png"
535                if not os.path.isfile(target_file):
536#                       shutil.copy("/usr/share/icons/hicolor/128x128/apps/lliurex-store.png",target_file)
537#                       if not os.fork():
538                        if not os.path.isfile(target_file):
539                                self._debug("Downloading %s to %s"%(url,target_file))
540                                try:
541                                        with urllib.request.urlopen(url) as response, open(target_file, 'wb') as out_file:
542                                                bf=16*1024
543                                                acumbf=0
544                                                file_size=int(response.info()['Content-Length'])
545                                                while True:
546                                                        if acumbf>=file_size:
547                                                            break
548                                                        shutil.copyfileobj(response, out_file,bf)
549                                                        acumbf=acumbf+bf
550                                        st = os.stat(target_file)
551                                except Exception as e:
552                                        self._debug("Unable to download %s"%url)
553                                        self._debug("Reason: %s"%e)
554                                        target_file=url
555#                               os._exit(0)
556                return(target_file)
557        #def _download_file
558       
559        def _chk_bundle_dir(self,outdir):
560                msg_status=True
561                if not os.path.isdir(outdir):
562                        try:
563                                os.makedirs(outdir)
564                        except Exception as e:
565                                msg_status=False
566                                print(e)
567                return(os.access(outdir,os.W_OK|os.R_OK|os.X_OK|os.F_OK))
568        #def _chk_bundle_dir
569       
570        def _init_appinfo(self):
571                appInfo={'appstream_id':'',\
572                'id':'',\
573                'name':'',\
574                'version':'',\
575                'releases':[],\
576                'package':'',\
577                'license':'',\
578                'summary':'',\
579                'description':'',\
580                'categories':[],\
581                'icon':'',\
582                'screenshot':'',\
583                'thumbnails':[],\
584                'video':'',\
585                'homepage':'',\
586                'installerUrl':'',\
587                'state':'',\
588                'depends':'',\
589                'kudos':'',\
590                'suggests':'',\
591                'extraInfo':'',\
592                'size':'',\
593                'bundle':'',\
594                'updatable':'',\
595                }
596                return(appInfo)
597        #def _init_appinfo
Note: See TracBrowser for help on using the repository browser.