From 17f17917c3d531aae5fc8ccbf0f64a718611d975 Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:32:21 -0400 Subject: [PATCH] feat(app-store): remove bundled packages, add theme search Drop the import.meta.glob from loader.ts so packages/*/index.tsx are no longer compiled into Vite chunks and embedded in the exe. Apps are now purely opt-in downloads: installing an app fetches its IIFE bundle from the remote registry, caches it in localStorage, and records the bundle URL so it can be re-downloaded automatically if the cache is evicted. - loader.ts delegates loadAppManifest to loadInstalledBundle (localStorage) - remoteRegistry.ts caches bundleUrl on download, exposes loadInstalledBundle, cleans up the url key on invalidate - AppStore: AppsTab simplified to show only the remote catalog; bundled state and loadBundled promise removed; handleToggle always downloads before install - ThemesTab: add search bar (matches AppsTab UX) with empty-state message --- app/frontend/src/apps/AppStore.tsx | 152 ++++++++++++------------ app/frontend/src/apps/loader.ts | 46 ++----- app/frontend/src/apps/remoteRegistry.ts | 17 ++- 3 files changed, 99 insertions(+), 116 deletions(-) diff --git a/app/frontend/src/apps/AppStore.tsx b/app/frontend/src/apps/AppStore.tsx index d5e6094..b592788 100644 --- a/app/frontend/src/apps/AppStore.tsx +++ b/app/frontend/src/apps/AppStore.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react' import { Download, Trash2, RefreshCw, Search, Check, X } from 'lucide-react' import type { AppManifest } from './types' -import { getAvailableAppIds, loadAppManifest } from './loader' import { getInstalledIds, installApp, uninstallApp } from './registry' import { fetchRemoteCatalog, loadRemoteBundle, invalidateRemoteBundle, @@ -15,7 +14,6 @@ import './AppStore.scss' type Tab = 'apps' | 'themes' type AppFilter = 'all' | 'installed' | 'updates' -// ── Merged app entry (local bundled manifest + remote registry metadata) ──────── interface MergedApp { id: string manifest: AppManifest | null @@ -27,7 +25,6 @@ interface MergedApp { // ── Apps Tab ────────────────────────────────────────────────────────────────── function AppsTab() { - const [bundled, setBundled] = useState([]) const [remote, setRemote] = useState([]) const [installed, setInstalled] = useState>(new Set(getInstalledIds())) const [search, setSearch] = useState('') @@ -37,31 +34,33 @@ function AppsTab() { useEffect(() => { let cancelled = false - - const loadBundled = Promise.all(getAvailableAppIds().map(loadAppManifest)).then(loaded => { - if (!cancelled) setBundled(loaded.filter((m): m is AppManifest => m != null)) - }) - - const loadRemote = fetchRemoteCatalog().then(catalog => { - if (!cancelled && catalog) setRemote(catalog.apps) - }) - - void Promise.all([loadBundled, loadRemote]).finally(() => { - if (!cancelled) setLoading(false) + void fetchRemoteCatalog().then(catalog => { + if (!cancelled) { + if (catalog) setRemote(catalog.apps) + setLoading(false) + } }) - return () => { cancelled = true } }, []) - const merged = buildMerged(bundled, remote, installed) + const merged: MergedApp[] = remote.map(r => { + const installedVersion = getInstalledBundleVersion(r.id) + return { + id: r.id, + manifest: null, + remote: r, + isInstalled: installed.has(r.id), + hasUpdate: !!installedVersion && compareVersions(r.version, installedVersion) > 0, + } + }) const filtered = merged.filter(a => { if (filter === 'installed' && !a.isInstalled) return false if (filter === 'updates' && !a.hasUpdate) return false if (search) { const q = search.toLowerCase() - const name = (a.manifest?.name ?? a.remote?.name ?? '').toLowerCase() - const desc = (a.manifest?.description ?? a.remote?.description ?? '').toLowerCase() + const name = (a.remote?.name ?? '').toLowerCase() + const desc = (a.remote?.description ?? '').toLowerCase() if (!name.includes(q) && !desc.includes(q)) return false } return true @@ -78,13 +77,10 @@ function AppsTab() { await uninstallApp(a.id) invalidateRemoteBundle(a.id) setInstalled(prev => { const s = new Set(prev); s.delete(a.id); return s }) - } else { - if (a.remote && !a.manifest) { - // Remote-only app: download bundle first - const manifest = await loadRemoteBundle(a.id, a.remote.bundleUrl) - if (!manifest) { setBusy(prev => { const s = new Set(prev); s.delete(a.id); return s }); return } - if (a.remote.version) setInstalledBundleVersion(a.id, a.remote.version) - } + } else if (a.remote) { + const manifest = await loadRemoteBundle(a.id, a.remote.bundleUrl) + if (!manifest) { setBusy(prev => { const s = new Set(prev); s.delete(a.id); return s }); return } + if (a.remote.version) setInstalledBundleVersion(a.id, a.remote.version) await installApp(a.id) setInstalled(prev => new Set([...prev, a.id])) } @@ -101,7 +97,6 @@ function AppsTab() { const manifest = await loadRemoteBundle(a.id, a.remote.bundleUrl) if (manifest && a.remote.version) { setInstalledBundleVersion(a.id, a.remote.version) - // Trigger re-merge setInstalled(prev => new Set(prev)) } } finally { @@ -170,17 +165,16 @@ interface AppCardProps { } function AppCard({ app, isBusy, onToggle, onUpdate }: AppCardProps) { - const name = app.manifest?.name ?? app.remote?.name ?? app.id - const desc = app.manifest?.description ?? app.remote?.description ?? '' - const author = app.manifest?.author ?? app.remote?.author ?? '' - const version = app.manifest?.version ?? app.remote?.version ?? '' - const icon = app.manifest?.sidebar?.icon + const name = app.remote?.name ?? app.id + const desc = app.remote?.description ?? '' + const author = app.remote?.author ?? '' + const version = app.remote?.version ?? '' return (
- {icon ? React.createElement(icon) : {name.charAt(0)}} + {name.charAt(0)}
{name}
@@ -222,32 +216,6 @@ function AppCard({ app, isBusy, onToggle, onUpdate }: AppCardProps) { ) } -function buildMerged( - bundled: AppManifest[], - remote: RemoteAppEntry[], - installed: Set, -): MergedApp[] { - const byId = new Map() - - for (const m of bundled) { - byId.set(m.id, { id: m.id, manifest: m, remote: null, isInstalled: installed.has(m.id), hasUpdate: false }) - } - - for (const r of remote) { - const existing = byId.get(r.id) - if (existing) { - existing.remote = r - // Check for update: registry version > locally installed bundle version - const installedVersion = existing.manifest?.version ?? getInstalledBundleVersion(r.id) - existing.hasUpdate = !!installedVersion && compareVersions(r.version, installedVersion) > 0 - } else { - byId.set(r.id, { id: r.id, manifest: null, remote: r, isInstalled: installed.has(r.id), hasUpdate: false }) - } - } - - return [...byId.values()] -} - function compareVersions(a: string, b: string): number { const pa = a.split('.').map(Number) const pb = b.split('.').map(Number) @@ -267,6 +235,7 @@ interface ThemesTabProps { function ThemesTab({ activeTheme, onApplyTheme }: ThemesTabProps) { const [remoteThemes, setRemoteThemes] = useState([]) + const [search, setSearch] = useState('') useEffect(() => { void fetchRemoteCatalog().then(catalog => { @@ -274,7 +243,6 @@ function ThemesTab({ activeTheme, onApplyTheme }: ThemesTabProps) { }) }, []) - // Merge built-in THEMES with remote metadata const builtinIds = Object.keys(THEMES) const merged = builtinIds.map(id => { const remote = remoteThemes.find(t => t.id === id) @@ -289,33 +257,58 @@ function ThemesTab({ activeTheme, onApplyTheme }: ThemesTabProps) { } }) - // Community themes from remote that are NOT built-in const community = remoteThemes.filter(t => !t.builtin && !builtinIds.includes(t.id)) + const matchSearch = (t: { name: string; description: string }) => { + if (!search) return true + const q = search.toLowerCase() + return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) + } + + const filteredMerged = merged.filter(matchSearch) + const filteredCommunity = community.filter(matchSearch) + return ( <> -
Built-in Themes
-
- {merged.map(t => ( - - ))} +
+ + setSearch(e.target.value)} + /> + {search && ( + + )}
- {community.length > 0 && ( + {filteredMerged.length > 0 && ( + <> +
Built-in Themes
+
+ {filteredMerged.map(t => ( + + ))} +
+ + )} + + {filteredCommunity.length > 0 && ( <>
Community Themes
- {community.map(t => ( + {filteredCommunity.map(t => ( )} + + {filteredMerged.length === 0 && filteredCommunity.length === 0 && ( +
No themes match your search.
+ )} ) } @@ -391,7 +388,6 @@ function ThemeCard({ id, name, description, author, official, preview, isActive, export default function AppStore() { const [tab, setTab] = useState('apps') const [activeTheme, setActiveTheme] = useState(() => { - // Read from data-theme attribute as best-effort before IPC resolves return document.documentElement.getAttribute('data-theme') ?? 'dark' }) diff --git a/app/frontend/src/apps/loader.ts b/app/frontend/src/apps/loader.ts index ebe2a56..4cd0d65 100644 --- a/app/frontend/src/apps/loader.ts +++ b/app/frontend/src/apps/loader.ts @@ -1,42 +1,14 @@ -// Discovers first-party app packages and lazily loads them. -// -// Vite's import.meta.glob turns this into a static map of id -> () => import(...), -// one chunk per package. A package's code is never fetched or parsed unless its -// loader function is actually called (i.e. the app is installed and mounted). +// App manifest loader — delegates to the remote bundle cache. +// Apps are downloaded on install and cached in localStorage; they are NOT +// bundled into the main Vite output. This keeps the host exe lean and makes +// every app a true opt-in download. import type { AppManifest } from './types' +import { loadInstalledBundle } from './remoteRegistry' -const modules = import.meta.glob<{ default: AppManifest }>('../../../../packages/*/index.tsx') +/** No apps are bundled at build time; always returns an empty list. */ +export function getAvailableAppIds(): string[] { return [] } -function idFromPath(path: string): string { - const match = path.match(/packages\/([^/]+)\/index\.tsx$/) - return match ? match[1] : path -} - -const loadersById = new Map Promise<{ default: AppManifest }>>() -for (const [path, loader] of Object.entries(modules)) { - loadersById.set(idFromPath(path), loader) -} - -/** All known first-party app package ids (regardless of install state). */ -export function getAvailableAppIds(): string[] { - return [...loadersById.keys()] -} - -const manifestCache = new Map>() - -/** Loads (and caches) an app's manifest. Resolves to null if no such app package exists. */ +/** Loads a manifest for an installed app from its cached bundle. */ export function loadAppManifest(id: string): Promise { - let promise = manifestCache.get(id) - if (promise) return promise - - const loader = loadersById.get(id) - promise = loader - ? loader().then(m => m.default).catch(err => { - console.warn(`[apps] Failed to load "${id}":`, err) - return null - }) - : Promise.resolve(null) - - manifestCache.set(id, promise) - return promise + return loadInstalledBundle(id) } diff --git a/app/frontend/src/apps/remoteRegistry.ts b/app/frontend/src/apps/remoteRegistry.ts index d7e745b..f6a124b 100644 --- a/app/frontend/src/apps/remoteRegistry.ts +++ b/app/frontend/src/apps/remoteRegistry.ts @@ -78,10 +78,14 @@ export function loadRemoteBundle(id: string, bundleUrl: string): Promise { + const bundleUrl = localStorage.getItem(`binder:remote-app:${id}:url`) ?? '' + return loadRemoteBundle(id, bundleUrl) +} + function _injectBundle(code: string): Promise { return new Promise(resolve => { (window as unknown as Record)['__binder_app__'] = undefined