Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 74 additions & 78 deletions app/frontend/src/apps/AppStore.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -27,7 +25,6 @@ interface MergedApp {
// ── Apps Tab ──────────────────────────────────────────────────────────────────

function AppsTab() {
const [bundled, setBundled] = useState<AppManifest[]>([])
const [remote, setRemote] = useState<RemoteAppEntry[]>([])
const [installed, setInstalled] = useState<Set<string>>(new Set(getInstalledIds()))
const [search, setSearch] = useState('')
Expand All @@ -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
Expand All @@ -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]))
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 (
<div className={`ps-card${app.isInstalled ? ' ps-card--installed' : ''}${app.hasUpdate ? ' ps-card--has-update' : ''}`}>
<div className="ps-card__top">
<div className="ps-card__icon">
{icon ? React.createElement(icon) : <span className="ps-card__icon-letter">{name.charAt(0)}</span>}
<span className="ps-card__icon-letter">{name.charAt(0)}</span>
</div>
<div className="ps-card__identity">
<div className="ps-card__name">{name}</div>
Expand Down Expand Up @@ -222,32 +216,6 @@ function AppCard({ app, isBusy, onToggle, onUpdate }: AppCardProps) {
)
}

function buildMerged(
bundled: AppManifest[],
remote: RemoteAppEntry[],
installed: Set<string>,
): MergedApp[] {
const byId = new Map<string, MergedApp>()

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)
Expand All @@ -267,14 +235,14 @@ interface ThemesTabProps {

function ThemesTab({ activeTheme, onApplyTheme }: ThemesTabProps) {
const [remoteThemes, setRemoteThemes] = useState<RemoteThemeEntry[]>([])
const [search, setSearch] = useState('')

useEffect(() => {
void fetchRemoteCatalog().then(catalog => {
if (catalog?.themes) setRemoteThemes(catalog.themes)
})
}, [])

// 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)
Expand All @@ -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 (
<>
<div className="ps-section-label">Built-in Themes</div>
<div className="ps-theme-grid">
{merged.map(t => (
<ThemeCard
key={t.id}
id={t.id}
name={t.name}
description={t.description}
author={t.author}
official={t.official}
preview={t.preview}
isActive={activeTheme === t.id}
onApply={onApplyTheme}
/>
))}
<div className="ps-search-wrap">
<span className="ps-search-icon"><Search size={13} aria-hidden /></span>
<input
className="ps-search"
placeholder="Search themes…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="ps-search-clear" onClick={() => setSearch('')}><X size={13} /></button>
)}
</div>

{community.length > 0 && (
{filteredMerged.length > 0 && (
<>
<div className="ps-section-label">Built-in Themes</div>
<div className="ps-theme-grid">
{filteredMerged.map(t => (
<ThemeCard
key={t.id}
id={t.id}
name={t.name}
description={t.description}
author={t.author}
official={t.official}
preview={t.preview}
isActive={activeTheme === t.id}
onApply={onApplyTheme}
/>
))}
</div>
</>
)}

{filteredCommunity.length > 0 && (
<>
<div className="ps-section-label ps-section-label--spaced">Community Themes</div>
<div className="ps-theme-grid">
{community.map(t => (
{filteredCommunity.map(t => (
<ThemeCard
key={t.id}
id={t.id}
Expand All @@ -331,6 +324,10 @@ function ThemesTab({ activeTheme, onApplyTheme }: ThemesTabProps) {
</div>
</>
)}

{filteredMerged.length === 0 && filteredCommunity.length === 0 && (
<div className="ps-empty">No themes match your search.</div>
)}
</>
)
}
Expand Down Expand Up @@ -391,7 +388,6 @@ function ThemeCard({ id, name, description, author, official, preview, isActive,
export default function AppStore() {
const [tab, setTab] = useState<Tab>('apps')
const [activeTheme, setActiveTheme] = useState(() => {
// Read from data-theme attribute as best-effort before IPC resolves
return document.documentElement.getAttribute('data-theme') ?? 'dark'
})

Expand Down
46 changes: 9 additions & 37 deletions app/frontend/src/apps/loader.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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<string, Promise<AppManifest | null>>()

/** 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<AppManifest | null> {
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)
}
17 changes: 16 additions & 1 deletion app/frontend/src/apps/remoteRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ export function loadRemoteBundle(id: string, bundleUrl: string): Promise<AppMani
try {
let code = localStorage.getItem(localKey)
if (!code) {
if (!bundleUrl) return null
const res = await fetch(bundleUrl)
if (!res.ok) return null
code = await res.text()
try { localStorage.setItem(localKey, code) } catch { /* quota */ }
try {
localStorage.setItem(localKey, code)
localStorage.setItem(`binder:remote-app:${id}:url`, bundleUrl)
} catch { /* quota */ }
}
return await _injectBundle(code)
} catch {
Expand All @@ -97,6 +101,7 @@ export function invalidateRemoteBundle(id: string): void {
_remoteCache.delete(id)
localStorage.removeItem(`binder:remote-app:${id}:bundle`)
localStorage.removeItem(`binder:remote-app:${id}:version`)
localStorage.removeItem(`binder:remote-app:${id}:url`)
}

export function getInstalledBundleVersion(id: string): string | null {
Expand All @@ -107,6 +112,16 @@ export function setInstalledBundleVersion(id: string, version: string): void {
localStorage.setItem(`binder:remote-app:${id}:version`, version)
}

/**
* Loads an installed app's bundle using its cached URL.
* Falls back to re-downloading if the bundle code was evicted from localStorage
* but the URL was retained. Returns null if neither is available.
*/
export function loadInstalledBundle(id: string): Promise<AppManifest | null> {
const bundleUrl = localStorage.getItem(`binder:remote-app:${id}:url`) ?? ''
return loadRemoteBundle(id, bundleUrl)
}

function _injectBundle(code: string): Promise<AppManifest | null> {
return new Promise(resolve => {
(window as unknown as Record<string, unknown>)['__binder_app__'] = undefined
Expand Down
Loading