diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b9b33cc..f5c4466e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +## [1.0.0-beta.3] - 2026-06-16 + +### Added +- Mobile Store redesigned into an Apple App Store-style layout: bottom tab bar (Discover/Apps/Agents/Search/Updates), a featured hero, horizontal app carousels with Get pills and star counts, full-screen search, and a device filter. +- Real cover banners and icons across the Store: OpenClaw, Hermes, Ollama, ComfyUI, n8n, and the self-hosted apps, plus a shared Stable Diffusion banner (the AUTOMATIC1111 build shown in grayscale to distinguish it). A shared AppIcon component falls back to a branded monogram when no logo exists, so no tile renders blank. + +### Fixed +- Installed apps in the mobile Store no longer show a non-interactive "Open" control; they show an honest installed status. +- Failed Store installs now surface a Retry action instead of failing silently. +- Store icons and cover images reset correctly when a reused tile switches to a different app. + ## [1.0.0-beta.2] - 2026-06-16 ### Added diff --git a/desktop/package.json b/desktop/package.json index 31ff3b092..8bdea65ea 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "tinyagentos-desktop", "private": true, - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/public/store-covers/code-server.webp b/desktop/public/store-covers/code-server.webp new file mode 100644 index 000000000..3a3d69cce Binary files /dev/null and b/desktop/public/store-covers/code-server.webp differ diff --git a/desktop/public/store-covers/comfyui.webp b/desktop/public/store-covers/comfyui.webp new file mode 100644 index 000000000..4465cf55a Binary files /dev/null and b/desktop/public/store-covers/comfyui.webp differ diff --git a/desktop/public/store-covers/hermes.webp b/desktop/public/store-covers/hermes.webp new file mode 100644 index 000000000..fe6889891 Binary files /dev/null and b/desktop/public/store-covers/hermes.webp differ diff --git a/desktop/public/store-covers/home-assistant.webp b/desktop/public/store-covers/home-assistant.webp new file mode 100644 index 000000000..8a620c802 Binary files /dev/null and b/desktop/public/store-covers/home-assistant.webp differ diff --git a/desktop/public/store-covers/immich.webp b/desktop/public/store-covers/immich.webp new file mode 100644 index 000000000..ece7402c5 Binary files /dev/null and b/desktop/public/store-covers/immich.webp differ diff --git a/desktop/public/store-covers/jellyfin.webp b/desktop/public/store-covers/jellyfin.webp new file mode 100644 index 000000000..7bbd193be Binary files /dev/null and b/desktop/public/store-covers/jellyfin.webp differ diff --git a/desktop/public/store-covers/n8n.webp b/desktop/public/store-covers/n8n.webp new file mode 100644 index 000000000..a1eb049fd Binary files /dev/null and b/desktop/public/store-covers/n8n.webp differ diff --git a/desktop/public/store-covers/nextcloud.webp b/desktop/public/store-covers/nextcloud.webp new file mode 100644 index 000000000..7f5e9e2d0 Binary files /dev/null and b/desktop/public/store-covers/nextcloud.webp differ diff --git a/desktop/public/store-covers/ollama.webp b/desktop/public/store-covers/ollama.webp new file mode 100644 index 000000000..cd46f82fe Binary files /dev/null and b/desktop/public/store-covers/ollama.webp differ diff --git a/desktop/public/store-covers/openclaw.webp b/desktop/public/store-covers/openclaw.webp new file mode 100644 index 000000000..88a0eb41f Binary files /dev/null and b/desktop/public/store-covers/openclaw.webp differ diff --git a/desktop/public/store-covers/radarr.webp b/desktop/public/store-covers/radarr.webp new file mode 100644 index 000000000..3c8fa11d7 Binary files /dev/null and b/desktop/public/store-covers/radarr.webp differ diff --git a/desktop/public/store-covers/sonarr.webp b/desktop/public/store-covers/sonarr.webp new file mode 100644 index 000000000..961530415 Binary files /dev/null and b/desktop/public/store-covers/sonarr.webp differ diff --git a/desktop/public/store-covers/stable-diffusion-bw.webp b/desktop/public/store-covers/stable-diffusion-bw.webp new file mode 100644 index 000000000..400f38385 Binary files /dev/null and b/desktop/public/store-covers/stable-diffusion-bw.webp differ diff --git a/desktop/public/store-covers/stable-diffusion.webp b/desktop/public/store-covers/stable-diffusion.webp new file mode 100644 index 000000000..8b3b42fb4 Binary files /dev/null and b/desktop/public/store-covers/stable-diffusion.webp differ diff --git a/desktop/public/store-covers/uptime-kuma.webp b/desktop/public/store-covers/uptime-kuma.webp new file mode 100644 index 000000000..aaba26086 Binary files /dev/null and b/desktop/public/store-covers/uptime-kuma.webp differ diff --git a/desktop/public/store-covers/vaultwarden.webp b/desktop/public/store-covers/vaultwarden.webp new file mode 100644 index 000000000..e951c3bdd Binary files /dev/null and b/desktop/public/store-covers/vaultwarden.webp differ diff --git a/desktop/src/apps/StoreApp/AppIcon.tsx b/desktop/src/apps/StoreApp/AppIcon.tsx new file mode 100644 index 000000000..b46d4ff52 --- /dev/null +++ b/desktop/src/apps/StoreApp/AppIcon.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useState } from "react"; +import type { CatalogApp } from "./types"; + +/* ------------------------------------------------------------------ + AppIcon - one icon surface for the whole Store (desktop + mobile). + + Resolution order: + 1. An explicit dashboard-icons slug (app.iconSlug), or a known + per-app icon URL (APP_ICONS), or a derived brand family. + 2. A slug derived from the app name, tried against the CDN. + 3. A branded monogram tile: the app's initials on a deterministic + per-app gradient. Every app gets a clean, intentional icon - + including the taOS agent frameworks that have no upstream logo. + + The monogram is also the graceful onError target, so a missing or + rate-limited CDN image never leaves a blank square. + ------------------------------------------------------------------ */ + +const di = (slug: string): string => + `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${slug}.png`; +const gh = (owner: string): string => `https://github.com/${owner}.png?size=96`; + +/* Per-app icon overrides for catalog entries that ship without an + iconSlug. dashboard-icons slugs are verified against the upstream + repo; GitHub-avatar fallbacks cover orgs the icon set does not carry. + Anything not listed (and the taOS frameworks) resolves to a monogram. */ +const APP_ICONS: Record = { + // Agent frameworks with a real upstream mark + smolagents: di("hugging-face"), + "openai-agents-sdk": di("openai"), + // Models + "qwen3-4b": di("qwen"), "qwen3-1.7b": di("qwen"), "qwen3-8b": di("qwen"), + "gemma-3-4b": di("google-gemini"), + // MCP / plugins + "github-mcp-server": di("github"), "mcp-memory": di("mcp"), + "playwright-mcp": gh("microsoft"), + // Services / dev tools / infra + searxng: di("searxng"), gitea: di("gitea"), n8n: di("n8n"), + "code-server": di("coder"), "code-server-kasm": di("coder"), + blender: di("blender"), libreoffice: di("libreoffice"), + "jupyter-lab": di("jupyter"), tailscale: di("tailscale"), + caddy: di("caddy"), animatediff: gh("guoyww"), + comfyui: di("comfyui"), ollama: di("ollama"), + "kokoro-tts": di("kokoro-web"), "whisper-stt": di("web-whisper"), + // Homelab (fallbacks for when the API omits iconSlug) + "home-assistant": di("home-assistant"), "uptime-kuma": di("uptime-kuma"), +}; + +/* dashboard-icons family fallbacks, derived from the app id prefix. */ +function familyIcon(id: string): string | null { + if (id.startsWith("qwen")) return di("qwen"); + if (id.startsWith("gemma")) return di("google-gemini"); + if (id.startsWith("llama")) return di("meta"); + if (id.startsWith("phi-")) return gh("microsoft"); + if (id.startsWith("whisper")) return di("web-whisper"); + if (id.startsWith("deepseek")) return di("deepseek"); + if (id.startsWith("mistral") || id.startsWith("mixtral")) return di("mistral-ai"); + if (id.startsWith("flux-")) return di("black-forest-labs"); + return null; +} + +/* Derive a dashboard-icons-style slug from a display name as a last + network attempt before the monogram. "Home Assistant" -> "home-assistant". */ +function slugFromName(name: string): string { + return name + .toLowerCase() + .replace(/\([^)]*\)/g, " ") // drop parenthetical notes + .replace(/[^a-z0-9]+/g, "-") // punctuation + spaces -> hyphen + .replace(/^-+|-+$/g, ""); +} + +/* The first network URL to try for an app, or null to go straight to + a name-derived slug. */ +function primaryIconUrl(app: CatalogApp): string | null { + if (app.iconSlug) return di(app.iconSlug); + if (APP_ICONS[app.id]) return APP_ICONS[app.id] ?? null; + return familyIcon(app.id); +} + +/* ------------------------------------------------------------------ + Monogram palette - deterministic per-app gradient. + + Tuned for the macOS-dark graphite shell: each pair is a deep, mid- + saturation duotone that sits behind a near-white glyph at >= 4.5:1. + Hues are spread across the wheel (slate, teal, green, amber, copper, + rose, blue, violet-grey) so neighbouring tiles read as distinct, + never an AI-purple default. + ------------------------------------------------------------------ */ +const MONOGRAM_GRADIENTS: Array<[string, string]> = [ + ["#3b4a63", "#222b3d"], // slate blue + ["#1f5d63", "#103138"], // teal + ["#2f5e44", "#16301f"], // forest green + ["#6b4a2a", "#2f2113"], // copper + ["#6a3f4f", "#2e1a22"], // rose + ["#3a4d7a", "#1b2440"], // indigo grey + ["#5a5230", "#2a2615"], // olive amber + ["#4a3a63", "#241b33"], // muted violet + ["#2c4f6b", "#13283a"], // ocean + ["#623838", "#2c1717"], // brick +]; + +function hashName(name: string): number { + let h = 0; + for (let i = 0; i < name.length; i++) { + h = (h * 31 + name.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +/* First 1-2 letters: initials of the first two words, or the first two + characters of a single word. "Agent Zero" -> "AZ", "OpenClaw" -> "OP". */ +function monogramText(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length >= 2) { + return (words[0]![0]! + words[1]![0]!).toUpperCase(); + } + const w = words[0] ?? "?"; + return w.slice(0, 2).toUpperCase(); +} + +function Monogram({ app, size, radius }: { app: CatalogApp; size: number; radius: number }) { + const text = monogramText(app.name); + const [from, to] = MONOGRAM_GRADIENTS[hashName(app.name) % MONOGRAM_GRADIENTS.length]!; + return ( +
+ 1 ? 0.4 : 0.5)), + fontWeight: 700, + letterSpacing: "-0.02em", + color: "rgba(255,255,255,0.94)", + textShadow: "0 1px 2px rgba(0,0,0,0.35)", + lineHeight: 1, + fontFamily: + "ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + }} + > + {text} + +
+ ); +} + +/* ------------------------------------------------------------------ + coverFor - the cover art behind a featured / carousel card. + + Honours an explicit app.cover (the homelab entries set rich, layered + gradients). For everything else, derives a deterministic two-stop + radial-plus-linear wash from the SAME hue family as the app's + monogram, so the icon and its cover read as one identity. Keeps the + taOS frameworks (OpenClaw, Hermes, ...) on-brand instead of flat. + ------------------------------------------------------------------ */ +export function coverFor(app: CatalogApp): string { + if (app.cover) return app.cover; + const [from, to] = MONOGRAM_GRADIENTS[hashName(app.name) % MONOGRAM_GRADIENTS.length]!; + return ( + `radial-gradient(120% 130% at 18% 16%, ${from}, transparent 58%),` + + `radial-gradient(120% 130% at 86% 82%, ${to}, transparent 60%),` + + `linear-gradient(140deg, #20202a, #14141a)` + ); +} + +/* ------------------------------------------------------------------ + StoreCover - the cover surface behind a featured / carousel card. + + With app.coverImage: the real photo fills the card (object-cover), + warmed by a faint top wash and a strong bottom-up dark scrim so the + icon, name and Get pill overlaid by the caller clear >= 4.5:1. + Without it (or if the image 404s / is offline): the designed + gradient from coverFor() shows instead, so a card is never blank. + + The caller positions its own footer/badges absolutely over this; the + scrim here is purely the legibility layer for that overlaid text. + ------------------------------------------------------------------ */ +export function StoreCover({ app }: { app: CatalogApp }) { + const [failed, setFailed] = useState(false); + // A reused instance must retry the new image: clear the failure flag + // whenever the cover URL changes, so a prior app's load error does not + // suppress the next app's cover. + useEffect(() => { setFailed(false); }, [app.coverImage]); + const gradient = coverFor(app); + const showImage = !!app.coverImage && !failed; + + return ( +
+ {showImage && ( + setFailed(true)} + /> + )} + {/* Top wash: takes the edge off bright screenshots behind a badge. */} +
+ {/* Bottom-up scrim: the legibility layer for the overlaid footer. */} +
+
+ ); +} + +/* ------------------------------------------------------------------ + AppIcon + ------------------------------------------------------------------ */ + +export function AppIcon({ + app, + size, + className = "", +}: { + app: CatalogApp; + /** Pixel edge length. Hero ~64, carousel ~56, row ~44. */ + size: number; + className?: string; +}) { + // Stage 0: explicit/known URL. Stage 1: name-derived CDN slug. + // Stage 2+: monogram. `stage` advances on each image load error. + const [stage, setStage] = useState(0); + const radius = Math.round(size * 0.23); + + const candidates = useMemo(() => { + const urls: string[] = []; + const primary = primaryIconUrl(app); + if (primary) urls.push(primary); + const derived = di(slugFromName(app.name)); + if (!urls.includes(derived)) urls.push(derived); + return urls; + }, [app]); + + // A reused instance must start from the first candidate for a new app: + // reset the resolution stage whenever the candidate URL set changes, so a + // stale error stage from a prior app does not skip straight to its monogram. + const candidateKey = candidates.join("|"); + useEffect(() => { setStage(0); }, [candidateKey]); + + const url = candidates[stage]; + const showMonogram = stage >= candidates.length; + + return ( +
+ {showMonogram ? ( + + ) : ( + setStage((s) => s + 1)} + /> + )} +
+ ); +} + +export default AppIcon; diff --git a/desktop/src/apps/StoreApp/MobileStore.test.tsx b/desktop/src/apps/StoreApp/MobileStore.test.tsx new file mode 100644 index 000000000..8e9c5b159 --- /dev/null +++ b/desktop/src/apps/StoreApp/MobileStore.test.tsx @@ -0,0 +1,101 @@ +// desktop/src/apps/StoreApp/MobileStore.test.tsx +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { MobileStore } from "./MobileStore"; +import { StoreCover, AppIcon } from "./AppIcon"; +import type { CatalogApp, InstallTarget } from "./types"; + +function app(over: Partial): CatalogApp { + return { + id: "x", name: "X", type: "agent-framework", version: "1.0.0", + description: "d", installed: false, compat: "green", ...over, + } as CatalogApp; +} + +const TARGETS: InstallTarget[] = []; + +function renderStore(apps: CatalogApp[], onInstall = vi.fn()) { + return render( + {}} + selectedBackends={[]} + compatMap={new Map()} + onInstall={onInstall} + />, + ); +} + +beforeEach(() => { + // jsdom does not implement Element.scrollTo; MobileStore calls it on tab change. + if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = (() => {}) as typeof Element.prototype.scrollTo; + } +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +describe("MobileStore GetButton", () => { + it("shows Get for a not-installed app and an honest Installed status (not a fake Open)", () => { + renderStore([ + app({ id: "a", name: "Alpha", stars: 100 }), + app({ id: "b", name: "Bravo", stars: 50, installed: true }), + ]); + expect(screen.getAllByText("Get").length).toBeGreaterThan(0); + expect(screen.getAllByText("Installed").length).toBeGreaterThan(0); + expect(screen.queryByText("Open")).toBeNull(); + // The installed indicator is a status, not a button masquerading as an action. + const status = screen.getAllByText("Installed")[0].closest("[role=status]"); + expect(status).not.toBeNull(); + }); + + it("surfaces a Retry affordance when the install request fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 500 }), + ); + renderStore([app({ id: "a", name: "Alpha", stars: 100 })]); + fireEvent.click(screen.getAllByText("Get")[0]); + await waitFor(() => expect(screen.getAllByText("Retry").length).toBeGreaterThan(0)); + }); + + it("calls onInstall when the install request succeeds", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{}", { status: 200 }), + ); + const onInstall = vi.fn(); + renderStore([app({ id: "a", name: "Alpha", stars: 100 })], onInstall); + fireEvent.click(screen.getAllByText("Get")[0]); + await waitFor(() => expect(onInstall).toHaveBeenCalledWith("a")); + }); +}); + +describe("StoreCover / AppIcon instance reuse", () => { + it("StoreCover retries the new image after a prior error when coverImage changes", () => { + const { rerender, container } = render( + , + ); + // Force the first image into a failed state. + fireEvent.error(container.querySelector("img")!); + expect(container.querySelector("img")).toBeNull(); + // A new app on the reused instance must retry its own cover. + rerender(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img!.getAttribute("src")).toBe("/b.webp"); + }); + + it("AppIcon resets to the first candidate icon when the app prop changes", () => { + const { rerender, container } = render( + , + ); + // Exhaust candidates so the first app falls back to its monogram. + let img = container.querySelector("img"); + while (img) { fireEvent.error(img); img = container.querySelector("img"); } + expect(container.querySelector("img")).toBeNull(); + // The reused instance must start a new app from its first icon candidate. + rerender(); + expect(container.querySelector("img")).not.toBeNull(); + }); +}); diff --git a/desktop/src/apps/StoreApp/MobileStore.tsx b/desktop/src/apps/StoreApp/MobileStore.tsx new file mode 100644 index 000000000..857d01627 --- /dev/null +++ b/desktop/src/apps/StoreApp/MobileStore.tsx @@ -0,0 +1,647 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { + Search, Check, Package, Loader2, Star, Compass, + Grid2x2, Bot, RefreshCw, ChevronRight, Cpu, X, ArrowDownToLine, +} from "lucide-react"; +import type { CatalogApp, InstallTarget } from "./types"; +import type { ResolveResponse } from "./resolver-types"; +import { filterCatalog, compatFromResolver } from "./filter"; +import { AppIcon, StoreCover } from "./AppIcon"; + +/* ------------------------------------------------------------------ + Mobile Store - Apple App Store-style presentation. + + Switched on from index.tsx via useIsMobile(). The desktop render + path is untouched. All catalog data, install logic, device/backend + filtering and resolver state are owned by StoreApp and passed in; + this file only restructures the PRESENTATION for a phone. + + Icons resolve through the shared AppIcon component (logo with a + branded-monogram fallback), so no app tile ever shows up blank. + ------------------------------------------------------------------ */ + +function formatStars(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +/* The row subtitle: a tagline reads in its own sentence case; the bare + category fallback (e.g. "agent-framework") gets de-slugged and capitalised. */ +function subtitleFor(app: CatalogApp): string { + if (app.tagline) return app.tagline; + const raw = (app.category || app.type).replace(/-/g, " "); + return raw.charAt(0).toUpperCase() + raw.slice(1); +} + +/* ------------------------------------------------------------------ + GetButton - the App Store pill. "Get" / spinner / "Open" + ------------------------------------------------------------------ */ + +function GetButton({ + app, onInstall, installTargets, +}: { + app: CatalogApp; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; +}) { + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + const handleGet = useCallback(async () => { + if (app.installed || busy) return; + setBusy(true); + setFailed(false); + try { + const target = installTargets[0]?.name ?? "local"; + const res = await fetch("/api/store/install-v2", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ app_id: app.id, target_remote: target }), + }); + if (res.ok) onInstall(app.id); + else setFailed(true); + } catch { setFailed(true); } + setBusy(false); + }, [app.id, app.installed, busy, installTargets, onInstall]); + + if (app.installed) { + // Installed services are managed elsewhere, not launched from the store, + // so this is an honest status indicator rather than a fake "Open" action. + return ( + + Installed + + ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------ + AppRow - App Store list row: icon + title + subtitle + Get pill + ------------------------------------------------------------------ */ + +function AppRow({ + app, onInstall, installTargets, +}: { + app: CatalogApp; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; +}) { + return ( +
+ +
+
{app.name}
+
{subtitleFor(app)}
+ {app.stars ? ( +
+ + {formatStars(app.stars)} +
+ ) : null} +
+ +
+ ); +} + +/* A vertical list of AppRows with hairline dividers between them. */ +function AppRowList({ + apps, onInstall, installTargets, +}: { + apps: CatalogApp[]; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; +}) { + return ( +
+ {apps.map((app) => ( + + ))} +
+ ); +} + +/* ------------------------------------------------------------------ + FeatureCard - large featured hero (Editor's Choice) and the + snap-scroll carousel cards. Full-bleed cover with overlaid meta. + ------------------------------------------------------------------ */ + +function FeatureCard({ + app, onInstall, installTargets, hero, +}: { + app: CatalogApp; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; + hero?: boolean; +}) { + return ( +
+ {/* Cover: real photo with gradient fallback + legibility scrim */} +
+ +
+ {hero && ( +
+ Editor's Choice +
+ )} + {/* Footer meta - sits over the cover scrim, so use white-on-dark */} +
+ +
+
{app.name}
+
+ {subtitleFor(app)} +
+
+ +
+
+ ); +} + +/* ------------------------------------------------------------------ + Carousel - horizontal snap-scroll strip with peek of the next card + ------------------------------------------------------------------ */ + +function Carousel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+
+ ); +} + +/* Section heading with optional "See all" affordance. */ +function SectionHead({ title, sub, onSeeAll }: { title: string; sub?: string; onSeeAll?: () => void }) { + return ( +
+
+ {sub &&
{sub}
} +

{title}

+
+ {onSeeAll && ( + + )} +
+ ); +} + +/* ------------------------------------------------------------------ + Tab bar + ------------------------------------------------------------------ */ + +type MobileTab = "discover" | "apps" | "agents" | "search" | "updates"; + +const TABS: { id: MobileTab; label: string; icon: React.ReactNode }[] = [ + { id: "discover", label: "Discover", icon: }, + { id: "apps", label: "Apps", icon: }, + { id: "agents", label: "Agents", icon: }, + { id: "search", label: "Search", icon: }, + { id: "updates", label: "Updates", icon: }, +]; + +function TabBar({ active, onSelect }: { active: MobileTab; onSelect: (t: MobileTab) => void }) { + return ( + + ); +} + +/* ------------------------------------------------------------------ + MobileStore + ------------------------------------------------------------------ */ + +const NAV_TYPE_MAP: Record = { + apps: ["streaming-app", "ai-app", "productivity", "home", "monitoring", "automation", "image-gen", "voice", "video-gen", "plugin"], + agents: ["agent-framework"], + updates: [], +}; + +interface Props { + apps: CatalogApp[]; + loading: boolean; + installTargets: InstallTarget[]; + selectedDevices: string[]; + onDevicesChange: (next: string[]) => void; + selectedBackends: string[]; + compatMap: Map; + onInstall: (id: string) => void; +} + +export function MobileStore({ + apps, loading, installTargets, selectedDevices, onDevicesChange, + selectedBackends, compatMap, onInstall, +}: Props) { + const [tab, setTab] = useState("discover"); + const [search, setSearch] = useState(""); + const [deviceSheet, setDeviceSheet] = useState(false); + const scrollRef = useRef(null); + const searchInputRef = useRef(null); + + // Reset scroll to top whenever the section changes. + useEffect(() => { scrollRef.current?.scrollTo({ top: 0 }); }, [tab]); + + // Focus the search field when the Search tab opens. + useEffect(() => { + if (tab === "search") { + const id = window.setTimeout(() => searchInputRef.current?.focus(), 60); + return () => window.clearTimeout(id); + } + }, [tab]); + + /* Device-aware compatible list for the current tab, reusing the same + helpers the desktop grid uses so device/backend filters and the model + resolver still gate what shows. */ + const compatibleFor = useCallback((pool: CatalogApp[]): CatalogApp[] => { + const selDevObjs = installTargets.filter((t) => selectedDevices.includes(t.name)); + const { compatible } = filterCatalog(pool, selDevObjs, selectedBackends); + return compatible.filter((a) => + a.type !== "model" || compatFromResolver(a.id, compatMap, false), + ); + }, [installTargets, selectedDevices, selectedBackends, compatMap]); + + const tabPool = useMemo(() => { + const types = NAV_TYPE_MAP[tab] ?? []; + if (tab === "updates") return apps.filter((a) => a.installed && a.update_available === true); + if (types.length === 0) return apps; + return apps.filter((a) => types.includes(a.type) || types.includes(a.category ?? "")); + }, [apps, tab]); + + const tabApps = useMemo(() => compatibleFor(tabPool), [compatibleFor, tabPool]); + + const searchResults = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return []; + return compatibleFor( + apps.filter((a) => a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q)), + ); + }, [apps, search, compatibleFor]); + + /* Discover sections, mirroring the desktop curation. */ + const discoverApps = useMemo(() => compatibleFor(apps), [compatibleFor, apps]); + const hero = useMemo( + () => discoverApps.find((a) => a.id === "comfyui") ?? discoverApps.find((a) => a.cover) ?? discoverApps[0], + [discoverApps], + ); + const popular = useMemo( + () => [...discoverApps].filter((a) => (a.stars ?? 0) > 0).sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0)).slice(0, 8), + [discoverApps], + ); + const subscriptions = useMemo(() => { + const ids = ["sonarr", "radarr", "qbittorrent", "sabnzbd", "homebridge", "adguard-home", "uptime-kuma", "nextcloud"]; + return ids.map((id) => discoverApps.find((a) => a.id === id)).filter(Boolean).slice(0, 6) as CatalogApp[]; + }, [discoverApps]); + const frameworks = useMemo( + () => discoverApps.filter((a) => a.type === "agent-framework" || a.category === "agent-framework").slice(0, 6), + [discoverApps], + ); + + // Header title per section. + const headerTitle = + tab === "discover" ? "Discover" : + tab === "apps" ? "Apps" : + tab === "agents" ? "Agents" : + tab === "search" ? "Search" : "Updates"; + + const selectedDeviceLabel = useMemo(() => { + if (selectedDevices.length === 0) return "All devices"; + if (selectedDevices.length === 1) { + const d = installTargets.find((t) => t.name === selectedDevices[0]); + return d?.friendly_name ?? d?.label ?? "1 device"; + } + return `${selectedDevices.length} devices`; + }, [selectedDevices, installTargets]); + + const goSearch = useCallback(() => setTab("search"), []); + + return ( +
+ {/* Sticky header */} +
+
+

{headerTitle}

+
+ {/* Device chip - folds the device pill bar into the header */} + {installTargets.length > 1 && ( + + )} + {tab !== "search" && ( + + )} +
+
+ {tab === "search" && ( +
+ + setSearch(e.target.value)} + placeholder="Apps, agents, models, MCP servers" + aria-label="Search the store" + className="w-full h-10 pl-9 pr-9 rounded-xl bg-shell-surface border border-shell-border text-[15px] text-shell-text placeholder:text-shell-text-tertiary focus-visible:outline-none focus-visible:border-shell-border-strong" + /> + {search && ( + + )} +
+ )} +
+ + {/* Scrolling feed */} +
+ {loading ? ( +
+ +
+ ) : tab === "search" ? ( + + ) : tab === "discover" ? ( +
+ {hero && ( +
+ +
+ )} + {popular.length > 0 && ( +
+ setTab("apps")} /> + + {popular.map((app) => ( + + ))} + +
+ )} + {subscriptions.length > 0 && ( +
+ + +
+ )} + {frameworks.length > 0 && ( +
+ setTab("agents")} /> + +
+ )} +
+ ) : ( + , line: "You're all up to date" } + : { icon: , line: "Nothing here yet" } + } + onBrowse={() => setTab("discover")} + /> + )} +
+ + {/* Bottom tab bar */} + + + {/* Device filter sheet */} + {deviceSheet && ( + setDeviceSheet(false)} + /> + )} +
+ ); +} + +/* ------------------------------------------------------------------ + SectionView - a plain App Store list for Apps / Agents / Updates + ------------------------------------------------------------------ */ + +function SectionView({ + title, apps, onInstall, installTargets, empty, onBrowse, +}: { + title: string; + apps: CatalogApp[]; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; + empty: { icon: React.ReactNode; line: string }; + onBrowse: () => void; +}) { + if (apps.length === 0) { + return ( +
+ {empty.icon} +

{empty.line}

+ +
+ ); + } + return ( +
+
{apps.length} {apps.length === 1 ? "result" : "results"} in {title}
+ +
+ ); +} + +/* ------------------------------------------------------------------ + SearchView + ------------------------------------------------------------------ */ + +function SearchView({ + search, results, onInstall, installTargets, +}: { + search: string; + results: CatalogApp[]; + onInstall: (id: string) => void; + installTargets: InstallTarget[]; +}) { + const q = search.trim(); + if (!q) { + return ( +
+ +

Find apps, agents, models and more

+
+ ); + } + if (results.length === 0) { + return ( +
+ +

No results for “{q}”

+
+ ); + } + return ( +
+
{results.length} {results.length === 1 ? "result" : "results"}
+ +
+ ); +} + +/* ------------------------------------------------------------------ + DeviceSheet - bottom sheet replacing the desktop device pill bar + ------------------------------------------------------------------ */ + +function DeviceSheet({ + installTargets, selected, onChange, onClose, +}: { + installTargets: InstallTarget[]; + selected: string[]; + onChange: (next: string[]) => void; + onClose: () => void; +}) { + const selSet = new Set(selected); + const toggle = (name: string) => { + onChange(selSet.has(name) ? selected.filter((n) => n !== name) : [...selected, name]); + }; + return ( +
+ + )} +
+
+ {installTargets.map((d) => { + const on = selSet.has(d.name); + return ( + + ); + })} +
+ +
+
+ ); +} + +export default MobileStore; diff --git a/desktop/src/apps/StoreApp/index.tsx b/desktop/src/apps/StoreApp/index.tsx index 05b3f1c68..92f4c6f5a 100644 --- a/desktop/src/apps/StoreApp/index.tsx +++ b/desktop/src/apps/StoreApp/index.tsx @@ -16,13 +16,9 @@ import { compatVisuals } from "./compat-visuals"; import { loadFilter, saveFilter } from "./storage"; import { emitAppEvent, APP_INSTALLED } from "@/lib/app-event-bus"; import { TaosAppsSection } from "./TaosAppsSection"; - -/* ------------------------------------------------------------------ - Dashboard-icons CDN helper - ------------------------------------------------------------------ */ - -const di = (slug: string) => - `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${slug}.png`; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { MobileStore } from "./MobileStore"; +import { AppIcon, StoreCover } from "./AppIcon"; /* ------------------------------------------------------------------ Nav sections @@ -72,6 +68,7 @@ const HOMELAB_APPS: CatalogApp[] = [ installed: false, compat: "green", repo: "home-assistant/core", iconSlug: "home-assistant", stars: 72400, cover: "radial-gradient(120% 120% at 30% 20%,#16607a,transparent 60%),linear-gradient(140deg,#0e2230,#0a1620)", + coverImage: "/desktop/store-covers/home-assistant.webp", category: "home", }, { @@ -81,6 +78,7 @@ const HOMELAB_APPS: CatalogApp[] = [ installed: false, compat: "green", repo: "immich-app/immich", iconSlug: "immich", stars: 50100, cover: "radial-gradient(120% 120% at 70% 30%,#5a2f7a,transparent 60%),linear-gradient(140deg,#21142b,#150d1a)", + coverImage: "/desktop/store-covers/immich.webp", category: "home", }, { @@ -90,6 +88,7 @@ const HOMELAB_APPS: CatalogApp[] = [ installed: false, compat: "green", repo: "jellyfin/jellyfin", iconSlug: "jellyfin", stars: 35000, cover: "radial-gradient(120% 120% at 40% 30%,#1f4d63,transparent 60%),linear-gradient(140deg,#10222a,#0b161b)", + coverImage: "/desktop/store-covers/jellyfin.webp", category: "home", }, { @@ -99,6 +98,7 @@ const HOMELAB_APPS: CatalogApp[] = [ installed: false, compat: "green", repo: "dani-garcia/vaultwarden", iconSlug: "vaultwarden", stars: 42000, cover: "radial-gradient(120% 120% at 60% 25%,#1f5a3a,transparent 60%),linear-gradient(140deg,#12261b,#0c1712)", + coverImage: "/desktop/store-covers/vaultwarden.webp", category: "home", }, { @@ -107,6 +107,7 @@ const HOMELAB_APPS: CatalogApp[] = [ tagline: "Smart PVR for Usenet and BitTorrent users", installed: false, compat: "green", repo: "Sonarr/Sonarr", iconSlug: "sonarr", stars: 11200, + coverImage: "/desktop/store-covers/sonarr.webp", category: "home", }, { @@ -115,6 +116,7 @@ const HOMELAB_APPS: CatalogApp[] = [ tagline: "Fork of Sonarr to work with movies", installed: false, compat: "green", repo: "Radarr/Radarr", iconSlug: "radarr", stars: 9600, + coverImage: "/desktop/store-covers/radarr.webp", category: "home", }, { @@ -155,6 +157,7 @@ const HOMELAB_APPS: CatalogApp[] = [ tagline: "Fancy self-hosted monitoring tool", installed: false, compat: "green", repo: "louislam/uptime-kuma", iconSlug: "uptime-kuma", stars: 60300, + coverImage: "/desktop/store-covers/uptime-kuma.webp", category: "monitoring", }, { @@ -163,6 +166,7 @@ const HOMELAB_APPS: CatalogApp[] = [ tagline: "The most popular self-hosted collaboration platform", installed: false, compat: "green", repo: "nextcloud/server", iconSlug: "nextcloud", stars: 28100, + coverImage: "/desktop/store-covers/nextcloud.webp", category: "productivity", }, ]; @@ -194,7 +198,7 @@ const MOCK_APPS: CatalogApp[] = [ // Services { id: "searxng", name: "SearXNG", type: "service", category: "infrastructure", version: "latest", description: "Privacy-respecting metasearch engine", installed: false, compat: "green" }, { id: "gitea", name: "Gitea", type: "service", category: "dev-tool", version: "latest", description: "Lightweight self-hosted Git service", installed: false, compat: "green" }, - { id: "n8n", name: "n8n", type: "service", category: "automation", version: "latest", description: "Workflow automation platform", installed: false, compat: "green", iconSlug: "n8n" }, + { id: "n8n", name: "n8n", type: "service", category: "automation", version: "latest", description: "Workflow automation platform", installed: false, compat: "green", iconSlug: "n8n", coverImage: "/desktop/store-covers/n8n.webp" }, // Streaming apps { id: "code-server-kasm", name: "Code Server (Streamed)", type: "streaming-app", version: "latest", description: "VS Code in the browser via KasmVNC", installed: false, compat: "green" }, { id: "blender", name: "Blender", type: "streaming-app", version: "latest", description: "3D creation suite streamed via KasmVNC", installed: false, compat: "yellow" }, @@ -207,6 +211,7 @@ const MOCK_APPS: CatalogApp[] = [ installed: false, compat: "yellow", iconSlug: "comfyui", cover: "radial-gradient(120% 140% at 12% 18%,#3a2d5e,transparent 55%),radial-gradient(120% 130% at 85% 80%,#1e4d63,transparent 55%),linear-gradient(120deg,#20202a,#14141a)", + coverImage: "/desktop/store-covers/comfyui.webp", }, { id: "fooocus", name: "Fooocus", type: "image-gen", version: "latest", description: "Simple Stable Diffusion with minimal setup", installed: false, compat: "yellow" }, // Audio / video / devtools / infra @@ -214,13 +219,33 @@ const MOCK_APPS: CatalogApp[] = [ { id: "whisper-stt", name: "Whisper STT", type: "voice", version: "latest", description: "OpenAI Whisper speech-to-text", installed: false, compat: "green" }, { id: "animatediff", name: "AnimateDiff", type: "video-gen",version: "latest", description: "AI video generation from text and images", installed: false, compat: "yellow" }, { id: "corridorkey", name: "CorridorKey", type: "video-gen",version: "latest", description: "AI video generation via ComfyUI workflows", installed: false, compat: "yellow" }, - { id: "code-server", name: "Code Server", type: "dev-tool", version: "latest", description: "VS Code in the browser -- remote development environment", installed: false, compat: "green" }, + { id: "code-server", name: "Code Server", type: "dev-tool", version: "latest", description: "VS Code in the browser -- remote development environment", installed: false, compat: "green", coverImage: "/desktop/store-covers/code-server.webp" }, { id: "jupyter-lab", name: "JupyterLab", type: "dev-tool", version: "latest", description: "Interactive notebooks for data science and experimentation", installed: false, compat: "green" }, { id: "tailscale", name: "Tailscale", type: "infrastructure", version: "latest", description: "Zero-config mesh VPN for secure networking between devices", installed: false, compat: "green", iconSlug: "tailscale" }, { id: "caddy", name: "Caddy", type: "infrastructure", version: "latest", description: "Automatic HTTPS reverse proxy and web server", installed: false, compat: "green" }, ...HOMELAB_APPS, ]; +/* Static cover art keyed by app id, so the designed gradient and the + real cover photo survive a live `/api/store/catalog` response that + doesn't carry them. Derived from the curated catalog above. */ +const COVER_BY_ID: Record = { + ...Object.fromEntries( + MOCK_APPS + .filter((a) => a.cover || a.coverImage) + .map((a) => [a.id, { cover: a.cover, coverImage: a.coverImage }]), + ), + // taOS agent frameworks: official banners (no dashboard-icons logo upstream), + // keyed by id so they resolve for both the curated and the backend-sourced rows. + openclaw: { coverImage: "/desktop/store-covers/openclaw.webp" }, + hermes: { coverImage: "/desktop/store-covers/hermes.webp" }, + ollama: { coverImage: "/desktop/store-covers/ollama.webp" }, + // Both Stable Diffusion WebUI cards share one banner; the AUTOMATIC1111 + // build (sd-webui) gets a grayscale cut so the two read as distinct. + "stable-diffusion-webui": { coverImage: "/desktop/store-covers/stable-diffusion.webp" }, + "sd-webui": { coverImage: "/desktop/store-covers/stable-diffusion-bw.webp" }, +}; + /* ------------------------------------------------------------------ Community showcase items (static mock -- no backend yet) ------------------------------------------------------------------ */ @@ -300,55 +325,6 @@ const FRAMEWORK_ITEMS: FrameworkItem[] = [ { id: "smolagents",name: "SmolAgents",sub: "Agent Framework", iconStyle: { background: "linear-gradient(150deg,#9aa0ad,#6e7686)" }, iconChar: "⬢", stars: 26000 }, ]; -/* ------------------------------------------------------------------ - Icon resolution: dashboard-icons CDN slug takes priority, then - existing APP_ICONS map, then derived family fallbacks. - ------------------------------------------------------------------ */ - -const si = (slug: string): string => `/static/store-icons/brands/${slug}.svg`; -const gh = (owner: string): string => `https://github.com/${owner}.png?size=96`; - -const APP_ICONS: Record = { - // Agent frameworks - "smolagents": gh("huggingface"), "pocketflow": gh("The-Pocket"), - "openclaw": "/static/store-icons/openclaw.jpg", - "openai-agents-sdk": si("openai"), "langroid": gh("langroid"), - // Models - "qwen3-4b": gh("QwenLM"), "qwen3-1.7b": gh("QwenLM"), "qwen3-8b": gh("QwenLM"), - "llama-3.1-8b": si("meta"), "llama-3.2-1b": si("meta"), - "gemma-3-4b": si("googlegemini"), - // MCP / plugins - "github-mcp-server": si("github"), "playwright-mcp": si("playwright"), - "mcp-memory": gh("modelcontextprotocol"), - // Services - "searxng": si("searxng"), "gitea": si("gitea"), "n8n": si("n8n"), - "code-server": gh("coder"), "code-server-kasm": gh("coder"), - "blender": si("blender"), "libreoffice": si("libreoffice"), - "jupyter-lab": si("jupyter"), "tailscale": si("tailscale"), - "caddy": gh("caddyserver"), "animatediff": gh("guoyww"), - "comfyui": gh("comfyanonymous"), "fooocus": gh("lllyasviel"), - "kokoro-tts": gh("hexgrad"), "whisper-stt": si("openai"), - // Homelab -- dashboard-icons CDN is handled via iconSlug on the app object; - // listing them here as fallback for when catalog comes from the API without iconSlug. - "home-assistant": si("homeassistant"), "uptime-kuma": si("uptimekuma"), -}; - -function resolveIconUrl(app: CatalogApp): string | null { - if (app.iconSlug) return di(app.iconSlug); - if (APP_ICONS[app.id]) return APP_ICONS[app.id] ?? null; - // Family fallbacks - if (app.id.startsWith("qwen")) return gh("QwenLM"); - if (app.id.startsWith("llama")) return si("meta"); - if (app.id.startsWith("gemma")) return si("googlegemini"); - if (app.id.startsWith("phi-")) return gh("microsoft"); - if (app.id.startsWith("whisper")) return si("openai"); - if (app.id.startsWith("deepseek")) return gh("deepseek-ai"); - if (app.id.startsWith("mistral") || app.id.startsWith("mixtral")) return gh("mistralai"); - if (app.id.startsWith("flux-")) return gh("black-forest-labs"); - if (app.id.startsWith("sd-") || app.id.startsWith("sdxl") || app.id.startsWith("sd3")) return gh("Stability-AI"); - return null; -} - function formatStars(n: number): string { if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); @@ -371,7 +347,6 @@ function AppCard({ resolveResponse?: ResolveResponse; }) { const [busy, setBusy] = useState(false); - const [iconFailed, setIconFailed] = useState(false); const [selectedTarget, setSelectedTarget] = useState(defaultTargetRemote ?? "local"); const [selectedVariant, setSelectedVariant] = useState("auto"); const [error, setError] = useState(null); @@ -403,7 +378,6 @@ function AppCard({ return () => { cancelled = true; }; }, [busy, app.id]); - const iconUrl = resolveIconUrl(app); const variantOptions = app.variants ?? []; const showVariantPicker = !app.installed && variantOptions.length > 1; const showTargetPicker = !app.installed && installTargets.length > 1; @@ -436,11 +410,9 @@ function AppCard({ style={{ borderColor: undefined }} title={visuals.tooltip || undefined} > - {/* Cover strip */} -
+ {/* Cover strip: real photo with gradient fallback */} +
+ {appType} @@ -448,11 +420,7 @@ function AppCard({ {/* Meta */}
-
- {iconUrl && !iconFailed - ? setIconFailed(true)} loading="lazy" /> - : } -
+
{app.name} @@ -536,11 +504,8 @@ function RichCard({ installTargets: InstallTarget[]; }) { const [busy, setBusy] = useState(false); - const [iconFailed, setIconFailed] = useState(false); const [error, setError] = useState(null); - const iconUrl = resolveIconUrl(app); - const handleGet = async () => { if (app.installed) return; setBusy(true); setError(null); @@ -561,8 +526,9 @@ function RichCard({ return (
- {/* Cover */} -
+ {/* Cover: real photo with gradient fallback */} +
+ {app.category || app.type} @@ -570,11 +536,7 @@ function RichCard({ {/* Meta */}
-
- {iconUrl && !iconFailed - ? setIconFailed(true)} loading="lazy" /> - : } -
+
{app.name}
{app.tagline ?? (app.category || app.type)}
@@ -614,8 +576,6 @@ function SubscriptionRow({ installTargets: InstallTarget[]; }) { const [busy, setBusy] = useState(false); - const [iconFailed, setIconFailed] = useState(false); - const iconUrl = resolveIconUrl(app); const handleGet = async () => { if (app.installed) return; @@ -629,11 +589,7 @@ function SubscriptionRow({ return (
-
- {iconUrl && !iconFailed - ? setIconFailed(true)} loading="lazy" /> - : } -
+
{app.name}
@@ -693,8 +649,6 @@ function CommunityCard({ item }: { item: CommunityItem }) { function HeroFeatured({ app, onInstall, installTargets }: { app: CatalogApp; onInstall: (id: string) => void; installTargets: InstallTarget[] }) { const [busy, setBusy] = useState(false); - const [iconFailed, setIconFailed] = useState(false); - const iconUrl = resolveIconUrl(app); const handleGet = async () => { if (app.installed) return; @@ -707,12 +661,11 @@ function HeroFeatured({ app, onInstall, installTargets }: { app: CatalogApp; onI }; return ( -
- {/* Scrim */} -
+
+ {/* Real photo cover with gradient fallback */} + + {/* Left-to-right scrim that anchors the text column */} +
Featured · Editor's Choice @@ -720,11 +673,7 @@ function HeroFeatured({ app, onInstall, installTargets }: { app: CatalogApp; onI

{app.name}

{app.description}

-
- {iconUrl && !iconFailed - ? setIconFailed(true)} loading="lazy" /> - : } -
+ @@ -877,6 +826,7 @@ function CommunityView() { ------------------------------------------------------------------ */ export function StoreApp({ windowId: _windowId }: { windowId: string }) { + const isMobile = useIsMobile(); const [apps, setApps] = useState([]); const [search, setSearch] = useState(""); const [activeNav, setActiveNav] = useState("discover"); @@ -926,7 +876,8 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) { iconSlug: a.iconSlug ? String(a.iconSlug) : undefined, stars: typeof a.stars === "number" ? a.stars : undefined, tagline: a.tagline ? String(a.tagline) : undefined, - cover: a.cover ? String(a.cover) : undefined, + cover: a.cover ? String(a.cover) : COVER_BY_ID[String(a.id)]?.cover, + coverImage: a.coverImage ? String(a.coverImage) : COVER_BY_ID[String(a.id)]?.coverImage, })); // Merge homelab apps: only add those not already in the catalog const catalogIds = new Set(normalized.map((a) => a.id)); @@ -1080,6 +1031,25 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) { const searching = search.trim().length > 0; const showGrid = searching || (activeNav !== "discover" && activeNav !== "community"); + // Mobile reads like the Apple App Store: bottom tab bar, full-width feed, + // snap-scroll carousels and a full-screen search. Same data and install + // handlers as desktop; only the presentation changes. The desktop render + // path below is left untouched. + if (isMobile) { + return ( + + ); + } + return (
{/* Sidebar */} diff --git a/desktop/src/apps/StoreApp/types.ts b/desktop/src/apps/StoreApp/types.ts index 1e5e805a8..3dbe9b6ee 100644 --- a/desktop/src/apps/StoreApp/types.ts +++ b/desktop/src/apps/StoreApp/types.ts @@ -25,6 +25,12 @@ export interface CatalogApp { tagline?: string; /** Cover art URL or gradient CSS value for rich cards. */ cover?: string; + /** + * Real cover photo (official screenshot / hero) shown behind a featured + * or carousel card. A bottom-up dark scrim keeps overlaid text legible. + * Falls back to `cover` (gradient) when absent or if the image fails to load. + */ + coverImage?: string; /** True when an installed app has a newer version available (drives Updates). */ update_available?: boolean; } diff --git a/docs/STATUS.md b/docs/STATUS.md index c835bba39..76f125230 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,13 @@ SINGLE SOURCE OF TRUTH for cross-agent handoff. -Last updated: 2026-06-16 ~14:25 BST, @taOS (ACTIVE). +Last updated: 2026-06-16 ~17:30 BST, @taOS (ACTIVE). + +▶▶ LATEST-13 2026-06-16 ~17:30 BST, dev=master=394c1c36 (canonical docs on origin/dev=994e04ed), Pi on feat/mobile-store-appstore (b4b2af7d) for Jay's mobile-Store promo preview: the LATEST-12 ICONS + COVERS follow-up is DONE on the #959 branch (NOT merged to dev, gated on Jay's promo shots). Real cover banners now wired via COVER_BY_ID (resolved by app id for backend-sourced catalog rows): openclaw, hermes, ollama, plus a shared Stable Diffusion banner across both WebUI cards -- the normal stable-diffusion-webui in color, the AUTOMATIC1111 build (sd-webui) a grayscale cut of the same banner so the two read as distinct. These sit on top of the earlier batch (comfyui/n8n/home-assistant/immich/jellyfin/vaultwarden/sonarr/radarr/uptime-kuma/nextcloud/code-server). All assets verified serving 200 image/webp from the Pi. tsc clean. NEXT unchanged: Jay grabs promo shots -> on his OK merge #959 to dev + promote to master (beta.3) + RETURN Pi to dev. Still open: fold #959 gitar findings (#85) before merge. + +▶▶ LATEST-12 2026-06-16 ~15:15 BST, dev=master=394c1c36 (release v1.0.0-beta.2), Pi TEMPORARILY on feat/mobile-store-appstore (942a0197) for Jay's mobile-Store promo preview, NOT on dev: MOBILE STORE redesigned to an Apple App Store layout (PR #959, base dev): bottom tab bar (Discover/Apps/Agents/Search/Updates), featured Editor's Choice hero, horizontal snap carousels (Popular now / Replace your subscriptions / Build agents with) with Get pills + star counts, full-screen Search, device-filter chip + bottom sheet; mobile-only via useIsMobile, desktop render path untouched, opaque bg, 42 Store tests pass. Jay: "looks much better." FOLLOW-UP IN FLIGHT (subagent on the same #959 branch): app ICONS + cover IMAGES, since the taOS agent frameworks (OpenClaw/Hermes/IronClaw/etc.) have no upstream dashboard-icons logo and render blank. Fix = a shared AppIcon component (real dashboard-icons logo when a slug exists, else a branded monogram-on-gradient tile) + correct slugs for the real apps (comfyui/n8n/ollama/dify/langroid/stable-diffusion/huggingface) + cover gradients for the hero + cards. NEXT: when icons land, deploy #959 to Pi -> Jay grabs promo shots -> on his OK, merge #959 to dev + promote to master (would be beta.3), then RETURN the Pi to dev. Open vs master: dependabot #954 (uv group), #476 App Runtime draft. Deferred 1-liner: validate the From header for CRLF in mail_client (gitar 💡). + +▶▶ LATEST-11 2026-06-16 ~14:45 BST, master = dev = Pi = 394c1c36: RELEASE v1.0.0-beta.2 SHIPPED (tagged + GitHub Release). Bundled and promoted dev->master: window move/resize jitter + off-screen recovery + dock right-click positioning (#957), mail security hardening (#955: IMAP UID validation, SMTP CRLF header-injection rejection, IPv6 _strip_port), versioning + changelog (#956: bump to 1.0.0-beta.2, Settings shows version numbers not commit SHAs, CHANGELOG.md + docs/RELEASING.md), and the cryptography 48.0.1 bump (#953). Pi is on dev=394c1c36 (current). DOCK RIGHT-CLICK BUG ROOT CAUSE (fixed in #957): the launch wrapper kept scale-100 (a transform makes a containing block for position:fixed), pinning the dock to the top and rendering context menus in a corner; fixed by dropping the steady-state transform AND portaling ContextMenu to document.body to escape the dock's own -translate-x-1/2. +INCIDENT + RECOVERY (learn from this): merging the dev->master PR (#958) AUTO-DELETED the dev branch, because the repo had delete_branch_on_merge=true (I enabled it earlier for feature-branch hygiene). The setting deletes the HEAD branch of ANY merged PR, and dev is the head of a dev->master PR. No PRs were wrongly closed (the only dev-targeting PRs were already merged). RECOVERED: disabled delete_branch_on_merge, recreated origin/dev from master (394c1c36, since dev was fully promoted), re-pointed the Pi with fetch --prune. RULE: keep delete_branch_on_merge OFF; the --delete-branch flag is not the only way dev gets nuked, the repo SETTING does it too. +NEXT: mobile Store overhaul (Apple App Store style) building via a craft subagent (impeccable + taste skills) for Jay's promo screenshots, PR incoming. Open vs master: dependabot #954 (uv group, 2 updates), #476 App Runtime draft. taOSmd released the 3060 for Jay (image-gen hold). ▶▶ LATEST-10 2026-06-16 ~14:25 BST, dev=d66ec15c, master=1fb8f000, Pi LIVE on fix/window-drag-jitter (7b13a0a1): the window-fix branch now ALSO carries OFF-SCREEN WINDOW RECOVERY (Jay was locked out: windows dragged/resized off-screen with no way back). safeBounds() recomputes the desktop area and recenters a window on restore / un-maximize when its title bar is no longer reachable; maximize now un-minimizes (so Maximise always shows it); new recenterWindow action + a "Center Window" item in the dock right-click menu as the direct recovery affordance. This is ON TOP of the move + resize jitter fixes (LATEST-9). Deployed to the Pi, tsc clean, window/store tests pass, for Jay to confirm move + resize + recovery before promoting. CORRECTION to LATEST-8/9: dependabot #953 (cryptography 46.0.7->48.0.1) merged to MASTER, not dev (dependabot PRs target the default branch). master=1fb8f000 has the crypto patch; DEV DOES NOT yet -> back-merge master->dev at the next promotion. New dependabot #954 (uv group, 2 updates) open vs master. OPEN DEV PRs (green-pending): #955 mail security fix-forward (gitar #952 findings: validate IMAP UID before FETCH, reject CRLF header injection in to/cc/subject, fix IPv6 _strip_port); #956 versioning+changelog (bump 1.0.0-beta.2 across the 3 version files, Settings Updates shows VERSION numbers not commit SHAs, backend stops hardcoding current_version 0.1.0 + adds new_version, CHANGELOG.md + docs/RELEASING.md). #81 Phase 1 still needs a real build pass (the subagent only planned it). NEXT: Jay confirms window move/resize/recovery -> PR fix/window-drag-jitter to dev -> merge #955 + #956 -> promote dev->master (back-merging master first for the crypto patch), tag v1.0.0-beta.2 + GitHub Release. diff --git a/pyproject.toml b/pyproject.toml index 7762a291b..f52a93c04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tinyagentos" -version = "1.0.0-beta.2" +version = "1.0.0-beta.3" description = "Self-hosted AI agent memory system for low-power hardware" license = { file = "LICENSE" } requires-python = ">=3.11" diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py index 80256cd39..81a2814f5 100644 --- a/tinyagentos/__init__.py +++ b/tinyagentos/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-beta.2" +__version__ = "1.0.0-beta.3"