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..61a92280 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,59 @@
}
_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, int, int]:
+ """Parse a semver string into a fixed-length (major, minor, patch) tuple.
+
+ 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:
+ parts = [int(p) for p in v.split(".")]
+ except ValueError:
+ 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:
"""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 +94,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 +131,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 +178,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 +203,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 +279,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}