Skip to content

Commit b5cb0c2

Browse files
MTSistemiclaude
andcommitted
dashboard: full app-store Hub (AppStream + Flatpak + Snap, catalogue/detail/reviews)
New skillfish-hub-catalog helper builds a 5200-app catalogue from AppStream (apt+flatpak), installed snaps (snapd) and ODRS ratings, plus live snap-find and ODRS reviews. Backend loads it in memory (background build) and serves: /api/hub/{status,catalog,categories,app,icon,search,installed,updates,sources,refresh}. Catalogue browsing by 9 categories + freedesktop subcategories, sort by popularity/name, backend filter. Install/remove now span apt/flatpak/snap (validated, list-form argv, background job log). hub.html is a full app-store: sidebar categories, app grid with icons/ratings/badges, app detail with screenshot carousel, description, releases and reviews, search across all backends, installed/updates/sources views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 94920c0 commit b5cb0c2

3 files changed

Lines changed: 795 additions & 141 deletions

File tree

apps/dashboard/skillfish-dashboardd

Lines changed: 242 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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

312321
def _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

339426
def 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

356480
def 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

Comments
 (0)