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

Last change on this file since 7139 was 7139, checked in by Juanma, 19 months ago

WIP Releases

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