From bba39d88d95e1599132b8d408d76788489b09714 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 16 Jun 2026 23:03:12 +0100 Subject: [PATCH 1/2] feat(apps): per-app versioning + Updates UI surface (#89 P1) --- desktop/package-lock.json | 4 +- .../apps/SettingsApp/UpdatesPanel.test.tsx | 70 ++++++++-- desktop/src/apps/SettingsApp/UpdatesPanel.tsx | 117 +++++++++++++++- desktop/src/apps/StoreApp/index.tsx | 35 ++++- .../apps/StoreApp/updates-optional.test.tsx | 99 ++++++++++++++ tests/test_apps_installed.py | 78 +++++++++++ tinyagentos/routes/apps.py | 128 ++++++++++++++++-- 7 files changed, 501 insertions(+), 30 deletions(-) create mode 100644 desktop/src/apps/StoreApp/updates-optional.test.tsx diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8d35fc2a..26f6a018 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta", + "version": "1.0.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta", + "version": "1.0.0-beta.3", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx index 3dca9f78..4c3543d8 100644 --- a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx @@ -4,19 +4,22 @@ import { UpdatesPanel } from "./UpdatesPanel"; const jResp = (b: any) => Promise.resolve({ ok: true, json: async () => b } as any); +const BASE_FETCH = async (url: string) => { + if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true }); + if (url === "/api/settings/update-check") + return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" }); + if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null }); + if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" }); + if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" }); + if (url === "/api/apps/optional/catalog") return jResp({ apps: [] }); + return jResp({}); +}; + beforeEach(() => { - global.fetch = vi.fn(async (url: string) => { - if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true }); - if (url === "/api/settings/update-check") - return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" }); - if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null }); - if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" }); - if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" }); - return jResp({}); - }) as any; + global.fetch = vi.fn(BASE_FETCH) as any; }); -describe("UpdatesPanel — version display", () => { +describe("UpdatesPanel -- version display", () => { it("shows current version prominently when up to date", async () => { render(); await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument()); @@ -34,6 +37,7 @@ describe("UpdatesPanel — version display", () => { new_commit: "xyz uvwx", }); if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null }); + if (url === "/api/apps/optional/catalog") return jResp({ apps: [] }); return jResp({}); }); render(); @@ -42,7 +46,7 @@ describe("UpdatesPanel — version display", () => { }); }); -describe("UpdatesPanel — branch selector", () => { +describe("UpdatesPanel -- branch selector", () => { it("hides the branch selector until Advanced is expanded", async () => { render(); expect(screen.queryByRole("combobox", { name: /branch/i })).toBeNull(); @@ -63,3 +67,47 @@ describe("UpdatesPanel — branch selector", () => { ); }); }); + +describe("UpdatesPanel -- optional apps section", () => { + it("renders nothing in the Apps section when no optional apps are installed", async () => { + (global.fetch as any) = vi.fn(async (url: string) => { + if (url === "/api/apps/optional/catalog") return jResp({ apps: [ + { id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: false, update_available: false }, + ]}); + return jResp(await BASE_FETCH(url).then((r) => r.json())); + }); + render(); + await waitFor(() => { + expect(screen.getByText("No optional apps installed.")).toBeInTheDocument(); + }); + }); + + it("renders an installed app row with its version and Core badge", async () => { + (global.fetch as any) = vi.fn(async (url: string) => { + if (url === "/api/apps/optional/catalog") return jResp({ apps: [ + { id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: true, update_available: false }, + ]}); + return jResp(await BASE_FETCH(url).then((r) => r.json())); + }); + render(); + await waitFor(() => expect(screen.getByText("Reddit")).toBeInTheDocument()); + expect(screen.getByText("1.0.0")).toBeInTheDocument(); + expect(screen.getByText("Core")).toBeInTheDocument(); + expect(screen.getByText("Up to date")).toBeInTheDocument(); + }); + + it("shows update available copy when catalog flags it", async () => { + (global.fetch as any) = vi.fn(async (url: string) => { + if (url === "/api/apps/optional/catalog") return jResp({ apps: [ + { id: "reddit", version: "1.0.0", trust: "first-party", source: "core", installed: true, update_available: true }, + ]}); + return jResp(await BASE_FETCH(url).then((r) => r.json())); + }); + render(); + await waitFor(() => + expect(screen.getByText(/Update available, included in the next system update/i)).toBeInTheDocument() + ); + // Should have a button/link to trigger the system update flow. + expect(screen.getByRole("button", { name: /scroll to system update/i })).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx index ec87b819..c5dbed81 100644 --- a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx +++ b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { Settings, RefreshCw, AlertCircle, Check, ChevronDown, ChevronRight } from "lucide-react"; +import { Settings, RefreshCw, AlertCircle, Check, ChevronDown, ChevronRight, Package } from "lucide-react"; +import * as LucideIcons from "lucide-react"; import { Button, Card, Label, Switch } from "@/components/ui"; import { RestartProgressModal } from "@/apps/SettingsApp/_shared"; +import { OPTIONAL_APPS } from "@/registry/optional-apps"; interface UpdateInfo { has_updates: boolean; @@ -27,6 +29,76 @@ interface BranchInfo { current: string; } +interface OptionalAppCatalogEntry { + id: string; + version: string; + trust: string; + source: "core" | string; + installed: boolean; + update_available: boolean; +} + +// Render a lucide icon by kebab-case name (matches the format used in optional-apps registry). +function AppIconGlyph({ iconName, size = 16 }: { iconName: string; size?: number }) { + const pascal = iconName + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); + const Glyph = (LucideIcons[pascal as keyof typeof LucideIcons] as LucideIcons.LucideIcon) ?? Package; + return ; +} + +// A single optional app row in the Updates panel. Keyed by `source` so a +// future "package" source can add its own Update button without touching this +// component layout. +function OptionalAppRow({ + entry, + displayName, + iconName, + onScrollToSystem, +}: { + entry: OptionalAppCatalogEntry; + displayName: string; + iconName: string; + onScrollToSystem: () => void; +}) { + return ( +
+
+ +
+
+
+ {displayName} + {entry.version} + {entry.source === "core" && ( + + Core + + )} +
+ {entry.source === "core" && entry.update_available ? ( +
+ + Update available, included in the next system update + + +
+ ) : ( + Up to date + )} +
+
+ ); +} + export function UpdatesPanel() { const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); @@ -36,6 +108,7 @@ export function UpdatesPanel() { const [prefs, setPrefs] = useState({ check_enabled: true }); const [updateStatus, setUpdateStatus] = useState(null); const [showRestartModal, setShowRestartModal] = useState(false); + const [optionalCatalog, setOptionalCatalog] = useState([]); // Advanced / branch selector state const [advancedOpen, setAdvancedOpen] = useState(false); @@ -45,6 +118,7 @@ export function UpdatesPanel() { const [showSwitchConfirm, setShowSwitchConfirm] = useState(false); const branchFetched = useRef(false); const confirmBtnRef = useRef(null); + const systemCardRef = useRef(null); // Focus management for the confirm dialog: move focus into the dialog when it // opens and return it to the previously focused control when it closes, so @@ -79,6 +153,13 @@ export function UpdatesPanel() { const r3 = await fetch("/api/settings/update-status"); if (r3.ok) setUpdateStatus(await r3.json()); } catch { /* ignore */ } + try { + const r4 = await fetch("/api/apps/optional/catalog"); + if (r4.ok) { + const data = await r4.json(); + if (data && Array.isArray(data.apps)) setOptionalCatalog(data.apps); + } + } catch { /* ignore */ } })(); }, []); @@ -121,7 +202,7 @@ export function UpdatesPanel() { try { const res = await fetch("/api/settings/update", { method: "POST" }); if (res.ok) { - // Server always restarts after a successful install — show the modal. + // Server always restarts after a successful install -- show the modal. setShowRestartModal(true); } else { const err = await res.json().catch(() => ({})); @@ -208,8 +289,15 @@ export function UpdatesPanel() { setSwitching(false); }; + const scrollToSystem = useCallback(() => { + systemCardRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + const hasPendingRestart = !!updateStatus?.pending_restart_sha; + // Only show installed optional apps that have a matching registry entry for display metadata. + const installedOptional = optionalCatalog.filter((e) => e.installed); + return (

Updates

@@ -226,7 +314,7 @@ export function UpdatesPanel() { )} - +
@@ -386,6 +474,29 @@ export function UpdatesPanel() {
+ {/* Apps section: installed optional apps with version + source badge */} +
+

Apps

+ {installedOptional.length === 0 ? ( +

No optional apps installed.

+ ) : ( + + {installedOptional.map((entry) => { + const meta = OPTIONAL_APPS.find((a) => a.id === entry.id); + return ( + + ); + })} + + )} +
+ {/* Branch-switch confirm dialog */} {showSwitchConfirm && (
([]); const [selectedBackends, setSelectedBackends] = useState([]); const [compatMap, setCompatMap] = useState>(new Map()); + const [optionalCatalog, setOptionalCatalog] = useState>([]); const userId = typeof window !== "undefined" ? window.localStorage.getItem("taos.user.id") || "anon" : "anon"; const profileId = typeof window !== "undefined" ? window.localStorage.getItem("taos.profile.id") || "default" : "default"; @@ -928,6 +930,7 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) { fetchLatestFrameworks().then(setLatest).catch(() => {}); fetch("/api/agents").then((r) => r.ok ? r.json() : []).then((j) => setAgentList(Array.isArray(j) ? j : (j?.agents ?? []))).catch(() => {}); fetch("/api/cluster/install-targets", { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (Array.isArray(data)) setInstallTargets(data); }).catch(() => {}); + fetch("/api/apps/optional/catalog", { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (data?.apps) setOptionalCatalog(data.apps); }).catch(() => {}); refreshInstalled(); }, [refreshInstalled]); @@ -1154,10 +1157,34 @@ export function StoreApp({ windowId: _windowId }: { windowId: string }) { No matches for “{search.trim()}”
) : activeNav === "updates" && filtered.length === 0 ? ( -
- - You’re all up to date -
+ <> + {(() => { + const updatableOptional = optionalCatalog.filter((e) => e.installed && e.update_available); + return updatableOptional.length > 0 ? ( +
+

taOS Apps

+
+ {updatableOptional.map((entry) => { + const meta = OPTIONAL_APPS.find((a) => a.id === entry.id); + return ( +
+ {meta?.name ?? entry.id} + {entry.version} + Core + Included in next system update +
+ ); + })} +
+
+ ) : ( +
+ + You’re all up to date +
+ ); + })()} + ) : activeNav === "installed" && filtered.length === 0 ? (
diff --git a/desktop/src/apps/StoreApp/updates-optional.test.tsx b/desktop/src/apps/StoreApp/updates-optional.test.tsx new file mode 100644 index 00000000..fbedd77c --- /dev/null +++ b/desktop/src/apps/StoreApp/updates-optional.test.tsx @@ -0,0 +1,99 @@ +/** + * Tests for the optional app versioning integration in the Store updates tab. + * + * The StoreApp renders the optional catalog as a distinct section inside the + * "updates" view. This test suite verifies: + * - The catalog endpoint is fetched on mount. + * - Installed optional apps with update_available=true appear in the updates tab. + * - The "all up to date" empty state is shown when nothing needs updating. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { StoreApp } from "./index"; + +// Minimal fetch stubs for all endpoints the StoreApp calls on mount. +function makeFetch(optionalCatalogApps: Array<{ + id: string; version: string; installed: boolean; update_available: boolean; + trust?: string; source?: string; +}>) { + return vi.fn(async (url: string) => { + if (url === "/api/store/catalog") { + return new Response("[]", { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "/api/store/installed-v2") { + return new Response(JSON.stringify({ installed: [] }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "/api/agents") { + return new Response("[]", { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "/api/cluster/install-targets") { + return new Response(JSON.stringify([{ name: "local", label: "Local", type: "local" }]), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "/api/apps/optional/catalog") { + return new Response( + JSON.stringify({ + apps: optionalCatalogApps.map((a) => ({ + trust: "first-party", source: "core", ...a, + })), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + // framework API + other endpoints: 404 is fine (caught silently) + return new Response(null, { status: 404 }); + }); +} + +beforeEach(() => { + // jsdom doesn't implement scrollTo + if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = (() => {}) as typeof Element.prototype.scrollTo; + } + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + })), + }); + } +}); + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +describe("Store updates tab -- optional app versioning", () => { + it("fetches the optional catalog on mount", async () => { + const mockFetch = makeFetch([]); + global.fetch = mockFetch as any; + render(); + await waitFor(() => + expect(mockFetch.mock.calls.some(([url]: [string]) => url === "/api/apps/optional/catalog")).toBe(true) + ); + }); + + it("shows all up to date empty state when no optional apps need updates", async () => { + global.fetch = makeFetch([ + { id: "reddit", version: "1.0.0", installed: true, update_available: false }, + ]) as any; + render(); + // Navigate to updates tab + const updatesBtn = await screen.findByRole("button", { name: /updates/i }); + fireEvent.click(updatesBtn); + await waitFor(() => expect(screen.getByText(/all up to date/i)).toBeInTheDocument()); + }); + + it("shows an installed optional app with update_available in the updates tab", async () => { + global.fetch = makeFetch([ + { id: "reddit", version: "1.0.0", installed: true, update_available: true }, + ]) as any; + render(); + const updatesBtn = await screen.findByRole("button", { name: /updates/i }); + fireEvent.click(updatesBtn); + await waitFor(() => expect(screen.getByText("Reddit")).toBeInTheDocument()); + expect(screen.getByText(/included in next system update/i)).toBeInTheDocument(); + // Core badge should be visible + expect(screen.getByText("Core")).toBeInTheDocument(); + }); +}); diff --git a/tests/test_apps_installed.py b/tests/test_apps_installed.py index cc4fb38e..6770666e 100644 --- a/tests/test_apps_installed.py +++ b/tests/test_apps_installed.py @@ -211,3 +211,81 @@ async def test_optional_apps_excluded_from_services_list(self, apps_client): resp = await client.get("/api/apps/installed") ids = {i["app_id"] for i in resp.json()} assert "github-browser" not in ids + + +class TestOptionalAppCatalog: + @pytest.mark.asyncio + async def test_catalog_returns_all_allowlisted_apps(self, apps_client): + """Catalog must include every id in OPTIONAL_FRONTEND_APPS with source=core.""" + from tinyagentos.routes.apps import OPTIONAL_FRONTEND_APPS + client, _ = apps_client + resp = await client.get("/api/apps/optional/catalog") + assert resp.status_code == 200 + data = resp.json() + assert "apps" in data + returned_ids = {a["id"] for a in data["apps"]} + assert returned_ids == OPTIONAL_FRONTEND_APPS + for app in data["apps"]: + assert app["source"] == "core" + assert "version" in app + assert "trust" in app + assert "installed" in app + assert "update_available" in app + + @pytest.mark.asyncio + async def test_install_records_app_versions_version(self, apps_client): + """Installing an app should record the APP_VERSIONS version in the DB.""" + from tinyagentos.routes.apps import APP_VERSIONS + client, store = apps_client + resp = await client.post("/api/apps/optional/reddit/install") + assert resp.status_code == 200 + rows = await store.list_installed() + row = next((r for r in rows if r["app_id"] == "reddit"), None) + assert row is not None + assert row["version"] == APP_VERSIONS["reddit"] + + @pytest.mark.asyncio + async def test_update_available_false_for_fresh_install(self, apps_client): + """A freshly installed app records the current version, so update_available=false.""" + client, _ = apps_client + await client.post("/api/apps/optional/youtube-library/install") + resp = await client.get("/api/apps/optional/catalog") + app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library") + assert app["installed"] is True + assert app["update_available"] is False + + @pytest.mark.asyncio + async def test_update_available_true_when_recorded_version_is_older(self, apps_client): + """update_available flips true when the stored version is behind APP_VERSIONS.""" + from tinyagentos.routes.apps import APP_VERSIONS + import json + client, store = apps_client + # Seed the DB with an older version directly. + await store._db.execute( + "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)", + ("x-monitor", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})), + ) + await store._db.commit() + resp = await client.get("/api/apps/optional/catalog") + app = next(a for a in resp.json()["apps"] if a["id"] == "x-monitor") + assert app["installed"] is True + # APP_VERSIONS["x-monitor"] is "1.0.0" which is > "0.9.0" + assert app["update_available"] is True + assert app["version"] == APP_VERSIONS["x-monitor"] + + @pytest.mark.asyncio + async def test_catalog_does_not_leak_unknown_ids(self, apps_client): + """Catalog must never return ids outside the allowlist.""" + from tinyagentos.routes.apps import OPTIONAL_FRONTEND_APPS + import json + client, store = apps_client + # Inject a foreign row directly (bypassing the install endpoint). + await store._db.execute( + "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)", + ("totally-random-app", 1000.0, "1.0.0", json.dumps({"kind": "frontend-app"})), + ) + await store._db.commit() + resp = await client.get("/api/apps/optional/catalog") + returned_ids = {a["id"] for a in resp.json()["apps"]} + assert "totally-random-app" not in returned_ids + assert returned_ids == OPTIONAL_FRONTEND_APPS diff --git a/tinyagentos/routes/apps.py b/tinyagentos/routes/apps.py index 081bd875..e941af8a 100644 --- a/tinyagentos/routes/apps.py +++ b/tinyagentos/routes/apps.py @@ -1,6 +1,6 @@ """Desktop service icon API. -GET /api/apps/installed — list installed services that have a recorded +GET /api/apps/installed -- list installed services that have a recorded runtime location (host + port). These are the apps that get desktop icons and can be opened in a taOS web-app window via the service proxy. @@ -34,15 +34,55 @@ } _FRONTEND_APP_KIND = "frontend-app" +# In-core version for each optional app. When an app becomes a real .taosapp +# package, the package version will win instead of this value. +APP_VERSIONS: dict[str, str] = { + "reddit": "1.0.0", + "youtube-library": "1.0.0", + "github-browser": "1.0.0", + "x-monitor": "1.0.0", + "coding-studio": "1.0.0", + "design-studio": "1.0.0", + "music-studio": "1.0.0", + "app-studio": "1.0.0", + "office-suite": "1.0.0", +} + +# Trust level for each optional app (all current optional apps are first-party). +APP_TRUST: dict[str, str] = { + "reddit": "first-party", + "youtube-library": "first-party", + "github-browser": "first-party", + "x-monitor": "first-party", + "coding-studio": "first-party", + "design-studio": "first-party", + "music-studio": "first-party", + "app-studio": "first-party", + "office-suite": "first-party", +} + + +def _semver_tuple(version: str) -> tuple[int, ...]: + """Parse a semver string into a comparable tuple of ints. + + Strips leading 'v' and ignores pre-release/build suffixes for ordering. + Returns (0,) on any parse failure so comparisons degrade gracefully. + """ + v = version.lstrip("v").split("-")[0].split("+")[0] + try: + return tuple(int(p) for p in v.split(".")) + except ValueError: + return (0,) + def _resolve_icon(manifest_icon: str, manifest_dir) -> str: """Resolve the manifest's icon field to a URL string. Accepts: - - Absolute URL paths like /static/app-icons/gitea.svg → returned as-is. - - http/https URLs → returned as-is. + - Absolute URL paths like /static/app-icons/gitea.svg -> returned as-is. + - http/https URLs -> returned as-is. - Relative paths (e.g. icons/gitea.svg) relative to - the manifest dir — not currently served, so fall back + the manifest dir -- not currently served, so fall back to the generic icon. Returns the generic icon if the field is empty. """ @@ -50,7 +90,7 @@ def _resolve_icon(manifest_icon: str, manifest_dir) -> str: return _GENERIC_ICON if manifest_icon.startswith("/") or manifest_icon.startswith("http"): return manifest_icon - # Relative path — would need extra static-mount work; use generic for now. + # Relative path -- would need extra static-mount work; use generic for now. return _GENERIC_ICON @@ -87,10 +127,10 @@ async def list_installed_apps(request: Request): app_id: str = row["app_id"] loc = await installed_apps.get_runtime_location(app_id) if loc is None: - # No runtime location → not accessible via proxy → skip. + # No runtime location -- not accessible via proxy -- skip. continue if not loc.get("runtime_host") or not loc.get("runtime_port"): - # Incomplete runtime record — not proxy-routable yet. + # Incomplete runtime record -- not proxy-routable yet. continue # Best-effort manifest lookup for display metadata. @@ -134,7 +174,7 @@ async def list_installed_apps(request: Request): # --------------------------------------------------------------------------- # -# Optional frontend apps (Reddit / YouTube / GitHub / X) — Store install state. +# Optional frontend apps -- Store install state and versioned catalog. # --------------------------------------------------------------------------- # @@ -159,9 +199,73 @@ async def list_installed_optional_apps(request: Request): return {"installed": installed} +@router.get("/api/apps/optional/catalog") +async def optional_app_catalog(request: Request): + """Return version and install state for every allowlisted optional app. + + Shape:: + + { + "apps": [ + { + "id": "reddit", + "version": "1.0.0", + "trust": "first-party", + "source": "core", + "installed": true, + "update_available": false + }, + ... + ] + } + + ``source`` is always "core" for in-bundle apps. A future .taosapp package + source would appear here alongside an independent Update button without any + UI rework. + + ``update_available`` is true only when the app is installed AND the version + recorded at install time is older than APP_VERSIONS (semver comparison). + Freshly installed apps always record the current APP_VERSIONS version, so + update_available will be false unless an older install row pre-dates a + version bump. + """ + store = getattr(request.app.state, "installed_apps", None) + + # Build an index of installed rows keyed by app_id for O(1) lookup. + installed_index: dict[str, dict] = {} + if store is not None: + rows = await store.list_installed() + for r in rows: + aid = r["app_id"] + if aid in OPTIONAL_FRONTEND_APPS and (r.get("metadata") or {}).get("kind") == _FRONTEND_APP_KIND: + installed_index[aid] = r + + result = [] + for app_id in sorted(OPTIONAL_FRONTEND_APPS): + current_version = APP_VERSIONS.get(app_id, "1.0.0") + row = installed_index.get(app_id) + is_installed = row is not None + update_available = False + if is_installed and row is not None: + recorded = row.get("version") or "" + if recorded: + update_available = _semver_tuple(recorded) < _semver_tuple(current_version) + + result.append({ + "id": app_id, + "version": current_version, + "trust": APP_TRUST.get(app_id, "first-party"), + "source": "core", + "installed": is_installed, + "update_available": update_available, + }) + + return {"apps": result} + + @router.post("/api/apps/optional/{app_id}/install") async def install_optional_app(app_id: str, request: Request): - """Mark an optional frontend app installed. Instant — no service is spawned. + """Mark an optional frontend app installed. Instant -- no service is spawned. Rejected unless app_id is in the OPTIONAL_FRONTEND_APPS allowlist so this endpoint can't seed arbitrary install rows. @@ -171,7 +275,11 @@ async def install_optional_app(app_id: str, request: Request): store = getattr(request.app.state, "installed_apps", None) if store is None: return JSONResponse({"error": "install store unavailable"}, status_code=503) - await store.install(app_id, metadata={"kind": _FRONTEND_APP_KIND}) + await store.install( + app_id, + version=APP_VERSIONS.get(app_id, "1.0.0"), + metadata={"kind": _FRONTEND_APP_KIND}, + ) return {"status": "installed", "app_id": app_id} From 2a69c361f1861b31e4b8d4fb4d0e29f91a59574c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 16 Jun 2026 23:19:25 +0100 Subject: [PATCH 2/2] fix(apps): address gitar findings on #89 P1 - Store: hoist the taOS Apps updates section above the grid so optional-app updates show even when framework updates also exist (was nested in the filtered.length===0 empty-state branch and hidden otherwise) - name/icon for optional apps now come from the app registry (getApp), so the five studios resolve proper names/icons instead of raw ids + generic icon - _semver_tuple pads to (major,minor,patch) so '1.0' and '1.0.0' compare equal, and returns (0,0,0) on parse failure so it never masks a real update --- desktop/src/apps/SettingsApp/UpdatesPanel.tsx | 7 ++- desktop/src/apps/StoreApp/index.tsx | 58 +++++++++---------- tinyagentos/routes/apps.py | 16 +++-- 3 files changed, 43 insertions(+), 38 deletions(-) diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx index c5dbed81..7418092a 100644 --- a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx +++ b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx @@ -3,7 +3,7 @@ import { Settings, RefreshCw, AlertCircle, Check, ChevronDown, ChevronRight, Pac import * as LucideIcons from "lucide-react"; import { Button, Card, Label, Switch } from "@/components/ui"; import { RestartProgressModal } from "@/apps/SettingsApp/_shared"; -import { OPTIONAL_APPS } from "@/registry/optional-apps"; +import { getApp } from "@/registry/app-registry"; interface UpdateInfo { has_updates: boolean; @@ -295,7 +295,8 @@ export function UpdatesPanel() { const hasPendingRestart = !!updateStatus?.pending_restart_sha; - // Only show installed optional apps that have a matching registry entry for display metadata. + // Installed optional apps; display name/icon come from the app registry + // (getApp covers every optional app, including the studios). const installedOptional = optionalCatalog.filter((e) => e.installed); return ( @@ -482,7 +483,7 @@ export function UpdatesPanel() { ) : ( {installedOptional.map((entry) => { - const meta = OPTIONAL_APPS.find((a) => a.id === entry.id); + const meta = getApp(entry.id); return ( {filtered.length} apps

+ {activeNav === "updates" && (() => { + const updatableOptional = optionalCatalog.filter((e) => e.installed && e.update_available); + if (updatableOptional.length === 0) return null; + return ( +
+

taOS Apps

+
+ {updatableOptional.map((entry) => { + const meta = getApp(entry.id); + return ( +
+ {meta?.name ?? entry.id} + {entry.version} + Core + Included in next system update +
+ ); + })} +
+
+ ); + })()} {searching && filtered.length === 0 ? (
No matches for “{search.trim()}”
) : activeNav === "updates" && filtered.length === 0 ? ( - <> - {(() => { - const updatableOptional = optionalCatalog.filter((e) => e.installed && e.update_available); - return updatableOptional.length > 0 ? ( -
-

taOS Apps

-
- {updatableOptional.map((entry) => { - const meta = OPTIONAL_APPS.find((a) => a.id === entry.id); - return ( -
- {meta?.name ?? entry.id} - {entry.version} - Core - Included in next system update -
- ); - })} -
-
- ) : ( -
- - You’re all up to date -
- ); - })()} - + optionalCatalog.some((e) => e.installed && e.update_available) ? null : ( +
+ + You’re all up to date +
+ ) ) : activeNav === "installed" && filtered.length === 0 ? (
diff --git a/tinyagentos/routes/apps.py b/tinyagentos/routes/apps.py index e941af8a..61a92280 100644 --- a/tinyagentos/routes/apps.py +++ b/tinyagentos/routes/apps.py @@ -62,17 +62,21 @@ } -def _semver_tuple(version: str) -> tuple[int, ...]: - """Parse a semver string into a comparable tuple of ints. +def _semver_tuple(version: str) -> tuple[int, int, int]: + """Parse a semver string into a fixed-length (major, minor, patch) tuple. - Strips leading 'v' and ignores pre-release/build suffixes for ordering. - Returns (0,) on any parse failure so comparisons degrade gracefully. + Strips a leading 'v' and ignores pre-release/build suffixes for ordering, + and pads to three components so "1.0" and "1.0.0" compare equal. Returns + (0, 0, 0) on any parse failure so comparisons degrade gracefully without + masking a real update. """ v = version.lstrip("v").split("-")[0].split("+")[0] try: - return tuple(int(p) for p in v.split(".")) + parts = [int(p) for p in v.split(".")] except ValueError: - return (0,) + return (0, 0, 0) + parts = (parts + [0, 0, 0])[:3] + return (parts[0], parts[1], parts[2]) def _resolve_icon(manifest_icon: str, manifest_dir) -> str: