2D Android apps get artwork in FramePort: the APK's launcher icon (manifest icon first, any bitmap density) and a generated cover/banner when no store art exists (scan time + one background backfill); one icon extractor for FramePort and Steam

This commit is contained in:
spoopyghosty0 committed 2026-10-02 18:35:15 -04:00
1 parent e729f3f4bc
commit cc3febfae4
8 files changed
+152 -57

No files matched your search

+36 -13
View File
@@ -20,7 +20,8 @@ KINDS = {
"APP_IMG_LOGO_TRANSPARENT": "logo",
"APP_IMG_ICON": "icon",
}
EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games)
# OculusDB's square cover (Rift games); FramePort's own cover/banner (no store art)
EXTRA_KINDS = ("square", "cover", "banner")
PICKED = ".picked" # marker: the art in this folder was chosen by the user (Find artwork); automatic fetches skip it
@@ -76,21 +77,43 @@ def fetch(package: str, apk: Path | None = None, refresh: bool = False,
return out, title
def apk_icon(apk: Path) -> bytes | None:
"""Largest PNG launcher icon inside the APK (mipmap/drawable)."""
def apk_icon(apk: Path | str | None) -> bytes | None:
"""The app's launcher icon from the APK as PNG bytes: the icon the manifest names (pyaxmlparser), else the
largest ic_launcher bitmap. None for vector/adaptive-only icons (no bitmap to use)."""
if not apk or not Path(apk).is_file():
return None
import io
from PIL import Image
dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi")
try:
with zipfile.ZipFile(apk) as z:
cands = [i for i in z.infolist() if i.filename.startswith("res/") and "ic_launcher" in i.filename
and i.filename.endswith((".png", ".webp"))]
if not cands:
cands = [i for i in z.infolist() if i.filename.startswith("res/mipmap") and i.filename.endswith(".png")]
if not cands:
return None
best = max(cands, key=lambda i: i.file_size)
data = z.read(best)
return data if data[:4] == b"\x89PNG" else None
except (zipfile.BadZipFile, OSError):
listed = set(z.namelist())
names = []
try:
from pyaxmlparser import APK
named = APK(str(apk)).get_app_icon()
if named:
names.append(named)
except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names
pass
names += sorted((n for n in listed if n.startswith("res/") and "ic_launcher" in n
and n.endswith((".png", ".webp")) and "foreground" not in n and "background" not in n),
key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens)))
for n in names:
if n not in listed or not n.endswith((".png", ".webp")):
continue
with Image.open(io.BytesIO(z.read(n))) as im:
if im.width < 48:
continue
out = io.BytesIO()
im.convert("RGBA").save(out, "PNG")
return out.getvalue()
except (zipfile.BadZipFile, OSError, ValueError):
return None
return None
def files(package: str) -> list[Path]:
+67 -40
View File
@@ -126,7 +126,44 @@ def steam_set(package: str, title: str = "", apk: str | Path | None = None) -> d
return result
PLACEHOLDER_VERSION = 1
PLACEHOLDER_VERSION = 2
STORE_KINDS = {"portrait", "square", "landscape", "hero"}
def ensure_cover(package: str) -> Path | None:
"""A cover for FramePort's own library when a game has no store art (e.g. a 2D Android app): the same name +
APK icon design as the Steam placeholder, saved as artwork/<pkg>/cover.jpg. None when store art exists."""
from ..core import library
d = fetch.artwork_dir(package)
if {p.stem for p in fetch.files(package)} & STORE_KINDS:
return None
g = library.game(package) or {}
if not any(d.glob("icon.*")): # the icon also shows in lists (Frame page)
icon = fetch.apk_icon(g.get("apk"))
if icon:
(d / "icon.png").write_bytes(icon)
# icon-only (the card and the game page show the name anyway): a tall cover for cards, a wide banner for the page
title = g.get("title") or package
cover, banner = d / "cover.jpg", d / "banner.jpg"
stamp = d / ".cover"
key = f"{PLACEHOLDER_VERSION}:{title}:{next((p.stat().st_size for p in d.glob('icon.*')), 0)}"
if cover.exists() and banner.exists() and stamp.exists() and stamp.read_text(encoding="utf-8") == key:
return cover
icon = _load_icon(next(iter(sorted(d.glob("icon.*"))), None), g.get("apk"))
background = _background_for(title)
# cover: icon a bit above the middle (the card's title sits at the bottom); banner: icon on the right (the game
# page draws the title on the left)
for path, size, side, centre in ((cover, (600, 900), 300, (0.5, 0.4)), (banner, (1600, 600), 320, (0.75, 0.5))):
im = background(size)
if icon is not None:
ic = icon.resize((side, side))
im.paste(ic, (round(size[0] * centre[0] - side / 2), round(size[1] * centre[1] - side / 2)), ic)
im.save(path, "JPEG", quality=90, optimize=True)
stamp.write_text(key, encoding="utf-8")
return cover
def steam_set_for(package: str) -> dict[str, Path]:
@@ -138,38 +175,16 @@ def steam_set_for(package: str) -> dict[str, Path]:
def _apk_icon(apk: str | Path | None):
"""The APK's launcher icon as a PIL image, or None (adaptive/vector icons have no bitmap to use)."""
if not apk or not Path(apk).is_file():
return None
"""The APK's launcher icon as a PIL image, or None (see fetch.apk_icon)."""
import io
import zipfile
from PIL import Image
try:
with zipfile.ZipFile(apk) as z:
names = []
try:
from pyaxmlparser import APK
icon = APK(str(apk)).get_app_icon()
if icon:
names.append(icon)
except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names
pass
dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi")
names += sorted((n for n in z.namelist() if "ic_launcher" in n and n.endswith((".png", ".webp"))
and "foreground" not in n and "background" not in n),
key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens)))
for n in names:
if n.endswith((".png", ".webp")) and n in z.namelist():
im = Image.open(io.BytesIO(z.read(n)))
im.load()
if im.width >= 48:
return im.convert("RGBA")
except Exception: # noqa: BLE001 - not a readable APK
data = fetch.apk_icon(apk)
if not data:
return None
return None
with Image.open(io.BytesIO(data)) as im:
return im.convert("RGBA")
def _font(size: int):
@@ -209,28 +224,40 @@ def _title_block(draw, text: str, box: tuple[int, int, int, int], max_size: int,
draw.text((x0 + (x1 - x0 - w) / 2, top + i * line_h), line, font=font, fill=fill)
def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]:
"""Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title."""
def _background_for(title: str):
"""A vertical gradient in a colour picked from the title: background(size) -> PIL image."""
import colorsys
from PIL import Image, ImageDraw
from PIL import Image
hue = int(hashlib.sha1(title.encode()).hexdigest()[:4], 16) / 0xFFFF
top = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.30, 0.45))
bottom = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.10, 0.40))
icon = None
if icon_file:
try:
with Image.open(icon_file) as im:
icon = im.convert("RGBA")
except Exception: # noqa: BLE001
icon = None
if icon is None:
icon = _apk_icon(apk)
def background(size):
mask = Image.linear_gradient("L").resize(size) # 0 at the top → 255 at the bottom
return Image.composite(Image.new("RGB", size, bottom), Image.new("RGB", size, top), mask)
return background
def _load_icon(icon_file: Path | None, apk):
from PIL import Image
if icon_file:
try:
with Image.open(icon_file) as im:
return im.convert("RGBA")
except Exception: # noqa: BLE001
pass
return _apk_icon(apk)
def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]:
"""Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title."""
from PIL import Image, ImageDraw
icon = _load_icon(icon_file, apk)
background = _background_for(title)
result: dict[str, Path] = {}
for kind, (w, h) in SIZES.items():
+2 -1
View File
@@ -12,7 +12,8 @@ from pathlib import Path
from ..core.paths import user_data_dir
from . import fetch
WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600}
WIDTHS = {"portrait": 400, "square": 400, "cover": 400, "banner": 1280, "landscape": 1280, "hero": 1280, "icon": 96,
"logo": 600}
_locks: dict[str, threading.Lock] = {}
_guard = threading.Lock()
+3
View File
@@ -59,6 +59,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
if art:
try:
artwork.fetch(entry["package"], Path(entry["apk"]))
from .artwork.steam import ensure_cover
ensure_cover(entry["package"]) # no store art (2D apps): a cover with its name and icon
except Exception: # noqa: BLE001
pass
if not entry.get("details"):
+17
View File
@@ -98,6 +98,7 @@ class FramePortApp:
self.go("welcome" if needed() else "library")
threading.Thread(target=self._startup, daemon=True).start()
threading.Thread(target=self._poll, daemon=True).start()
threading.Thread(target=self._backfill_covers, daemon=True).start()
self.updater.start()
# ================================================================== shell
@@ -1293,6 +1294,22 @@ class FramePortApp:
self.refresh_view()
return True
def _backfill_covers(self) -> None:
"""Games without store art (2D Android apps) get a cover with their name and icon (once; cached)."""
from ..artwork import steam
made = 0
for g in library.games():
if g.get("kind") == "rift":
continue
try:
had = (steam.fetch.artwork_dir(g["package"]) / "cover.jpg").exists()
made += bool(steam.ensure_cover(g["package"])) and not had
except Exception: # noqa: BLE001 - artwork is optional
applog.log.info("cover for %s failed", g.get("package"), exc_info=True)
if made:
self.refresh_view()
def _keep_frame_awake(self) -> None:
"""Hold a wake lock on the Frame while jobs that use it run or wait (renewed every 30 min; it expires on its
own after an hour if FramePort goes away), release it when they're done."""
+1 -1
View File
@@ -49,7 +49,7 @@ class GameView:
from .library import display_title
a = g["analysis"]
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square"), 1280, wait=False)
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square", "banner"), 1280, wait=False)
platform = C.platform(g)[1]
facts = " · ".join(x for x in (platform, a.get("engine"), a.get("xr")) if x and x != "?")
recipe = g.get("recipe") or {}
+3 -2
View File
@@ -132,6 +132,7 @@ def save_filters(f: dict) -> None:
# ------------------------------------------------------------------------------------------ view
BATCH = 8
CARD_ART = ("portrait", "square", "cover", "icon") # store art first; cover = FramePort's own (no store art)
def card_shadow(hover: bool = False) -> ft.BoxShadow:
"""Library cards float on the dark background; hovering lifts them further."""
if hover:
@@ -511,14 +512,14 @@ class LibraryView:
pkg = g["package"]
job = self.app.jobs.busy_with(pkg)
return (display_title(g, tw), (g.get("recipe") or {}).get("status"), C.install_state(g, self.app.frame_info),
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, ("portrait", "square", "icon")),
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, CARD_ART),
self.app.frame_state)
def card(self, g: dict, pc: set[str], tw: set[str]) -> ft.Control:
app = self.app
pkg = g["package"]
rift = g.get("kind") == "rift"
art = thumbs.url(pkg, ("portrait", "square", "icon"))
art = thumbs.url(pkg, CARD_ART)
state = C.install_state(g, app.frame_info)
on_pc = pkg in pc
status = (g.get("recipe") or {}).get("status", "unknown")
+23
View File
@@ -310,3 +310,26 @@ def test_picked_art_survives_installs(monkeypatch):
assert (d / "portrait.png").read_bytes() == png("blue") and (d / "icon.png").exists()
fetch.fetch("com.picked", refresh=True) # "Find automatically": the store art replaces everything
assert (d / "portrait.png").read_bytes() == png("red")
def test_apps_without_store_art_get_a_cover(tmp_path):
"""FramePort's library shows a cover (name + APK icon) for apps no store knows; store art always wins."""
import zipfile
from PIL import Image
from frameport.artwork import fetch, steam, thumbs
from frameport.core import library
icon = tmp_path / "icon.png"
Image.new("RGBA", (96, 96), (10, 200, 30, 255)).save(icon)
apk = tmp_path / "app.apk"
with zipfile.ZipFile(apk, "w") as z:
z.write(icon, "res/mipmap-xxxhdpi-v4/ic_launcher.png")
library.upsert_game("org.example.flat", title="Flat App", apk=str(apk))
cover = steam.ensure_cover("org.example.flat")
assert cover and cover.name == "cover.jpg" and (fetch.artwork_dir("org.example.flat") / "icon.png").exists()
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "cover.jpg"
Image.new("RGB", (60, 90)).save(fetch.artwork_dir("org.example.flat") / "portrait.jpg") # store art arrives
assert steam.ensure_cover("org.example.flat") is None
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "portrait.jpg"