@@ -303,54 +303,178 @@ def launch_app(cmd):
303303 return {"ok" : False , "error" : str (e )}
304304
305305
306- # ---------------- Hub (apt package management ) ----------------
306+ # ---------------- Hub (full app store: apt + flatpak + snap ) ----------------
307307_HUBJOB = {"running" : False , "title" : "" , "log" : [], "done" : True , "rc" : 0 }
308308_HUBLOCK = threading .Lock ()
309- _PKG_RE = re .compile (r"^[a-z0-9][a-z0-9.+-]{0,80}$" )
309+ _PKG_RE = re .compile (r"^[a-z0-9][a-z0-9.+-]{0,80}$" ) # apt
310+ _FLAT_RE = re .compile (r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$" ) # flatpak app id
311+ _SNAP_RE = re .compile (r"^[a-z0-9][a-z0-9-]{0,80}$" ) # snap name
312+ HUB_CATALOG = "/usr/local/bin/skillfish-hub-catalog"
313+ _ICON_ROOTS = ["/usr/share/app-info/icons" , "/var/lib/app-info/icons" , "/usr/share/icons" ,
314+ "/usr/share/pixmaps" , "/var/lib/flatpak" ]
315+ # In-memory app catalogue, built in the background from skillfish-hub-catalog.
316+ HUBCAT = {"apps" : [], "by_key" : {}, "ready" : False , "building" : False , "ts" : 0 }
317+ _CARD = ("key" , "backend" , "id" , "pkgid" , "name" , "summary" , "icon" ,
318+ "top" , "rating" , "rating_n" , "installed" , "ver" , "developer" )
310319
311320
312321def _apt_env ():
313322 e = dict (os .environ ); e ["DEBIAN_FRONTEND" ] = "noninteractive" ; return e
314323
315324
316- def hub_updates ():
325+ def _card (a ):
326+ c = {k : a .get (k ) for k in _CARD }
327+ c ["iloc" ] = bool (a .get ("icon_local" ))
328+ return c
329+
330+
331+ def _cat_build ():
332+ if HUBCAT ["building" ]:
333+ return
334+ HUBCAT ["building" ] = True
317335 try :
318- out = subprocess .run (["apt-get" , "-s" , "full-upgrade" ], capture_output = True ,
319- text = True , timeout = 60 , env = _apt_env ()).stdout
336+ p = subprocess .run ([HUB_CATALOG , "build" ], capture_output = True , text = True , timeout = 180 )
337+ apps = json .loads (p .stdout ).get ("apps" , [])
338+ HUBCAT ["apps" ] = apps
339+ HUBCAT ["by_key" ] = {a ["key" ]: a for a in apps }
340+ HUBCAT ["ready" ] = True
341+ HUBCAT ["ts" ] = int (time .time ())
320342 except Exception as e :
321- return {"ok" : False , "error" : str (e ), "updates" : []}
322- ups = []
323- for ln in out .splitlines ():
324- m = re .match (r"Inst (\S+) \[([^\]]*)\] \(([^ ]+)" , ln )
325- if m :
326- ups .append ({"pkg" : m .group (1 ), "old" : m .group (2 ), "new" : m .group (3 )})
327- return {"ok" : True , "updates" : ups , "count" : len (ups )}
343+ sys .stderr .write ("hub catalog build failed: %s\n " % e )
344+ finally :
345+ HUBCAT ["building" ] = False
328346
329347
330- def hub_installed ():
348+ def cat_build_async ():
349+ threading .Thread (target = _cat_build , daemon = True ).start ()
350+
351+
352+ def hub_status ():
353+ return {"ok" : True , "ready" : HUBCAT ["ready" ], "building" : HUBCAT ["building" ],
354+ "count" : len (HUBCAT ["apps" ]), "ts" : HUBCAT ["ts" ]}
355+
356+
357+ def hub_catalog (p ):
358+ if not HUBCAT ["ready" ]:
359+ if not HUBCAT ["building" ]:
360+ cat_build_async ()
361+ return {"ok" : True , "ready" : False , "apps" : [], "total" : 0 }
362+ cat = p .get ("cat" ); sub = p .get ("sub" ); bk = p .get ("backend" )
363+ q = (p .get ("q" ) or "" ).lower ().strip (); sort = p .get ("sort" , "name" )
364+ try :
365+ page = max (0 , int (p .get ("page" , 0 )))
366+ except Exception :
367+ page = 0
368+ per = 60
369+ res = []
370+ for a in HUBCAT ["apps" ]:
371+ if cat and a .get ("top" ) != cat :
372+ continue
373+ if sub and sub not in (a .get ("cats" ) or []):
374+ continue
375+ if bk and a .get ("backend" ) != bk :
376+ continue
377+ if q and q not in a .get ("name" , "" ).lower () and q not in (a .get ("summary" ) or "" ).lower ():
378+ continue
379+ res .append (a )
380+ if sort == "rating" :
381+ res .sort (key = lambda a : (a .get ("rating_n" , 0 ), a .get ("rating" , 0 )), reverse = True )
382+ elif sort == "installed" :
383+ res .sort (key = lambda a : (not a .get ("installed" ), a .get ("name" , "" ).lower ()))
384+ else :
385+ res .sort (key = lambda a : a .get ("name" , "" ).lower ())
386+ total = len (res )
387+ cards = [_card (a ) for a in res [page * per :(page + 1 ) * per ]]
388+ return {"ok" : True , "ready" : True , "total" : total , "page" : page , "per" : per , "apps" : cards }
389+
390+
391+ def hub_categories ():
392+ counts = {}
393+ for a in HUBCAT ["apps" ]:
394+ counts [a .get ("top" )] = counts .get (a .get ("top" ), 0 ) + 1
395+ return {"ok" : True , "ready" : HUBCAT ["ready" ], "counts" : counts , "total" : len (HUBCAT ["apps" ])}
396+
397+
398+ def hub_app (key ):
399+ a = HUBCAT ["by_key" ].get (key )
400+ if not a :
401+ return {"ok" : False , "error" : "app non trovata" }
402+ a = dict (a )
403+ a ["iloc" ] = bool (a .get ("icon_local" ))
331404 try :
332- man = subprocess .run (["apt-mark" , "showmanual" ], capture_output = True ,
333- text = True , timeout = 30 ).stdout .split ()
405+ p = subprocess .run ([HUB_CATALOG , "reviews" , a .get ("id" , "" ), a .get ("ver" , "" )],
406+ capture_output = True , text = True , timeout = 20 )
407+ a ["reviews" ] = json .loads (p .stdout )
334408 except Exception :
335- man = []
336- return {"ok" : True , "installed" : sorted (set (man )), "count" : len (set (man ))}
409+ a ["reviews" ] = []
410+ return {"ok" : True , "app" : a }
411+
412+
413+ def hub_icon_path (key ):
414+ a = HUBCAT ["by_key" ].get (key )
415+ if not a :
416+ return None
417+ p = a .get ("icon_local" )
418+ if not p :
419+ return None
420+ rp = os .path .realpath (p )
421+ if not any (rp == os .path .realpath (r ) or rp .startswith (os .path .realpath (r ) + os .sep ) for r in _ICON_ROOTS ):
422+ return None
423+ return rp if os .path .isfile (rp ) else None
337424
338425
339426def hub_search (q ):
340427 q = (q or "" ).strip ()
341428 if len (q ) < 2 :
342429 return {"ok" : True , "results" : []}
430+ ql = q .lower ()
431+ res = [a for a in HUBCAT ["apps" ] if a .get ("backend" ) in ("apt" , "flatpak" )
432+ and (ql in a .get ("name" , "" ).lower () or ql in (a .get ("summary" ) or "" ).lower ()
433+ or ql in a .get ("pkgid" , "" ).lower ())][:80 ]
434+ snaps = []
343435 try :
344- out = subprocess .run (["apt-cache" , "search" , "--names-only" , q ],
345- capture_output = True , text = True , timeout = 30 ).stdout
346- except Exception as e :
347- return {"ok" : False , "error" : str (e ), "results" : []}
348- res = []
349- for ln in out .splitlines ()[:80 ]:
350- if " - " in ln :
351- name , _ , desc = ln .partition (" - " )
352- res .append ({"pkg" : name .strip (), "desc" : desc .strip ()})
353- return {"ok" : True , "results" : res }
436+ p = subprocess .run ([HUB_CATALOG , "snap-find" , q ], capture_output = True , text = True , timeout = 25 )
437+ snaps = json .loads (p .stdout )
438+ except Exception :
439+ snaps = []
440+ return {"ok" : True , "results" : [_card (a ) for a in res ] + [_card (s ) for s in snaps ]}
441+
442+
443+ def hub_installed ():
444+ res = [_card (a ) for a in HUBCAT ["apps" ] if a .get ("installed" )]
445+ res .sort (key = lambda a : a .get ("name" , "" ).lower ())
446+ return {"ok" : True , "installed" : res , "count" : len (res )}
447+
448+
449+ def hub_updates ():
450+ ups = []
451+ try :
452+ out = subprocess .run (["apt-get" , "-s" , "full-upgrade" ], capture_output = True ,
453+ text = True , timeout = 60 , env = _apt_env ()).stdout
454+ for ln in out .splitlines ():
455+ m = re .match (r"Inst (\S+) \[([^\]]*)\] \(([^ ]+)" , ln )
456+ if m :
457+ ups .append ({"backend" : "apt" , "pkg" : m .group (1 ), "old" : m .group (2 ), "new" : m .group (3 )})
458+ except Exception :
459+ pass
460+ try :
461+ out = subprocess .run (["flatpak" , "remote-ls" , "--updates" , "--columns=application,version" ],
462+ capture_output = True , text = True , timeout = 40 ).stdout
463+ for ln in out .splitlines ():
464+ f = ln .split ("\t " )
465+ if f and f [0 ] and f [0 ] != "Application ID" :
466+ ups .append ({"backend" : "flatpak" , "pkg" : f [0 ], "old" : "" , "new" : f [1 ] if len (f ) > 1 else "" })
467+ except Exception :
468+ pass
469+ try :
470+ out = subprocess .run (["snap" , "refresh" , "--list" ], capture_output = True , text = True , timeout = 40 ).stdout
471+ for ln in out .splitlines ()[1 :]:
472+ f = ln .split ()
473+ if f :
474+ ups .append ({"backend" : "snap" , "pkg" : f [0 ], "old" : "" , "new" : f [1 ] if len (f ) > 1 else "" })
475+ except Exception :
476+ pass
477+ return {"ok" : True , "updates" : ups , "count" : len (ups )}
354478
355479
356480def hub_sources ():
@@ -367,11 +491,21 @@ def hub_sources():
367491 out .append ({"name" : fn [:- 8 ], "enabled" : en , "uri" : mu .group (1 ) if mu else "" })
368492 except Exception :
369493 pass
370- return {"ok" : True , "sources" : out }
494+ remotes = []
495+ try :
496+ o = subprocess .run (["flatpak" , "remotes" , "--columns=name,url" ],
497+ capture_output = True , text = True , timeout = 15 ).stdout
498+ for ln in o .splitlines ():
499+ f = ln .split ("\t " )
500+ if f and f [0 ]:
501+ remotes .append ({"name" : f [0 ].strip (), "uri" : f [1 ].strip () if len (f ) > 1 else "" })
502+ except Exception :
503+ pass
504+ return {"ok" : True , "sources" : out , "flatpak_remotes" : remotes }
371505
372506
373- def _hub_run (title , cmds ):
374- """Run a list of argv commands sequentially in a background thread, streaming to _HUBJOB['log'] ."""
507+ def _hub_run (title , cmds , stop_on_error = True ):
508+ """Run a list of argv commands sequentially in a background thread; refresh catalogue after ."""
375509 with _HUBLOCK :
376510 if _HUBJOB ["running" ]:
377511 return False
@@ -387,31 +521,53 @@ def _hub_run(title, cmds):
387521 _HUBJOB ["log" ].append (line .rstrip ())
388522 if len (_HUBJOB ["log" ]) > 600 :
389523 del _HUBJOB ["log" ][:150 ]
390- p .wait (); rc = p .returncode
391- if rc != 0 :
392- break
524+ p .wait ()
525+ if p .returncode != 0 :
526+ rc = p .returncode
527+ if stop_on_error :
528+ break
393529 except Exception as e :
394530 _HUBJOB ["log" ].append ("errore: %s" % e ); rc = 1
395531 finally :
396532 _HUBJOB ["rc" ] = rc ; _HUBJOB ["running" ] = False ; _HUBJOB ["done" ] = True
533+ cat_build_async () # refresh installed state
397534
398535 threading .Thread (target = worker , daemon = True ).start ()
399536 return True
400537
401538
402- def hub_op (op , pkg ):
539+ def hub_op (op , backend , pkg ):
403540 pkg = (pkg or "" ).strip ()
404- if op in ("install" , "remove" ) and not _PKG_RE .match (pkg ):
405- return {"ok" : False , "error" : "nome pacchetto non valido" }
406- cmds = {
407- "update" : [["apt-get" , "update" ]],
408- "upgrade" : [["apt-get" , "update" ], ["apt-get" , "-y" , "full-upgrade" ]],
409- "install" : [["apt-get" , "update" ], ["apt-get" , "install" , "-y" , pkg ]],
410- "remove" : [["apt-get" , "purge" , "-y" , pkg ], ["apt-get" , "autoremove" , "-y" ]],
411- }.get (op )
412- if not cmds :
541+ if op == "update" :
542+ cmds = [["apt-get" , "update" ]]
543+ elif op == "upgrade" :
544+ cmds = [["apt-get" , "update" ], ["apt-get" , "-y" , "full-upgrade" ],
545+ ["flatpak" , "update" , "--system" , "-y" , "--noninteractive" ], ["snap" , "refresh" ]]
546+ return _hub_start ("aggiorno tutto" , cmds , stop_on_error = False )
547+ elif op in ("install" , "remove" ):
548+ if backend == "apt" :
549+ if not _PKG_RE .match (pkg ):
550+ return {"ok" : False , "error" : "nome pacchetto non valido" }
551+ cmds = ([["apt-get" , "update" ], ["apt-get" , "install" , "-y" , pkg ]] if op == "install"
552+ else [["apt-get" , "purge" , "-y" , pkg ], ["apt-get" , "autoremove" , "-y" ]])
553+ elif backend == "flatpak" :
554+ if not _FLAT_RE .match (pkg ):
555+ return {"ok" : False , "error" : "id flatpak non valido" }
556+ cmds = ([["flatpak" , "install" , "--system" , "-y" , "--noninteractive" , "flathub" , pkg ]] if op == "install"
557+ else [["flatpak" , "uninstall" , "--system" , "-y" , "--noninteractive" , pkg ]])
558+ elif backend == "snap" :
559+ if not _SNAP_RE .match (pkg ):
560+ return {"ok" : False , "error" : "nome snap non valido" }
561+ cmds = [["snap" , "install" , pkg ]] if op == "install" else [["snap" , "remove" , pkg ]]
562+ else :
563+ return {"ok" : False , "error" : "backend sconosciuto" }
564+ else :
413565 return {"ok" : False , "error" : "operazione sconosciuta" }
414- if not _hub_run (op + ((" " + pkg ) if pkg else "" ), cmds ):
566+ return _hub_start (op + ((" " + pkg ) if pkg else "" ), cmds )
567+
568+
569+ def _hub_start (title , cmds , stop_on_error = True ):
570+ if not _hub_run (title , cmds , stop_on_error ):
415571 return {"ok" : False , "error" : "operazione già in corso" }
416572 return {"ok" : True , "started" : True }
417573
@@ -963,6 +1119,40 @@ class Handler(BaseHTTPRequestHandler):
9631119 if not self ._guard ("zerotier" ):
9641120 return
9651121 return self ._json (200 , zt_status ())
1122+ if path == "/api/hub/status" :
1123+ if not self ._guard ("hub" ):
1124+ return
1125+ return self ._json (200 , hub_status ())
1126+ if path == "/api/hub/catalog" :
1127+ if not self ._guard ("hub" ):
1128+ return
1129+ qs = urllib .parse .parse_qs (urllib .parse .urlparse (self .path ).query )
1130+ p = {k : v [0 ] for k , v in qs .items ()}
1131+ return self ._json (200 , hub_catalog (p ))
1132+ if path == "/api/hub/categories" :
1133+ if not self ._guard ("hub" ):
1134+ return
1135+ return self ._json (200 , hub_categories ())
1136+ if path == "/api/hub/app" :
1137+ if not self ._guard ("hub" ):
1138+ return
1139+ key = urllib .parse .parse_qs (urllib .parse .urlparse (self .path ).query ).get ("key" , ["" ])[0 ]
1140+ return self ._json (200 , hub_app (key ))
1141+ if path == "/api/hub/icon" :
1142+ if not self ._guard ("hub" ):
1143+ return
1144+ key = urllib .parse .parse_qs (urllib .parse .urlparse (self .path ).query ).get ("key" , ["" ])[0 ]
1145+ ip = hub_icon_path (key )
1146+ if not ip :
1147+ return self ._json (404 , {"error" : "no icon" })
1148+ ext = os .path .splitext (ip )[1 ].lower ()
1149+ ct = {".png" : "image/png" , ".svg" : "image/svg+xml" , ".jpg" : "image/jpeg" ,
1150+ ".jpeg" : "image/jpeg" , ".xpm" : "image/x-xpixmap" }.get (ext , "application/octet-stream" )
1151+ try :
1152+ with open (ip , "rb" ) as f :
1153+ return self ._send (200 , f .read (), ct , [("Cache-Control" , "max-age=86400" )])
1154+ except Exception :
1155+ return self ._json (404 , {"error" : "no icon" })
9661156 if path == "/api/hub/updates" :
9671157 if not self ._guard ("hub" ):
9681158 return
@@ -1054,7 +1244,12 @@ class Handler(BaseHTTPRequestHandler):
10541244 if path == "/api/hub/op" :
10551245 if not self ._guard ("hub" ):
10561246 return
1057- return self ._json (200 , hub_op (data .get ("op" ), data .get ("pkg" )))
1247+ return self ._json (200 , hub_op (data .get ("op" ), data .get ("backend" , "apt" ), data .get ("pkg" )))
1248+ if path == "/api/hub/refresh" :
1249+ if not self ._guard ("hub" ):
1250+ return
1251+ cat_build_async ()
1252+ return self ._json (200 , {"ok" : True , "building" : True })
10581253 if path == "/api/hub/source" :
10591254 if not self ._guard ("hub" ):
10601255 return
@@ -1241,6 +1436,8 @@ def main():
12411436 sys .stderr .write ("SkillFish Remote on https://%s:%d (PAM=%s)\n " % (bind , port , _PAM_OK ))
12421437 sys .stderr .flush ()
12431438 threading .Thread (target = rules_loop , daemon = True ).start ()
1439+ if CONFIG .get ("modules" , {}).get ("hub" ):
1440+ cat_build_async () # pre-build the app catalogue in the background
12441441 httpd .serve_forever ()
12451442
12461443
0 commit comments