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
58 changes: 48 additions & 10 deletions src/apps/desktop/src/api/miniapp_market_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use bitfun_services_integrations::miniapp_market::{
RatingAggregate, ValidatedMarketPackage,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow};
use tokio::io::AsyncWriteExt;
Expand Down Expand Up @@ -248,15 +249,7 @@ pub async fn miniapp_market_installed_status(
.await
.map_err(|error| error.to_string())?;
for app in apps {
let Some(metadata) = state
.miniapp_manager
.load_customization_metadata(&app.id)
.await
.map_err(|error| error.to_string())?
else {
continue;
};
let Some(origin) = metadata.origin.market else {
let Some((origin, local_override)) = load_market_origin(&state, &app.id).await? else {
continue;
};
if origin.listing_id == request.listing_id {
Expand All @@ -265,13 +258,58 @@ pub async fn miniapp_market_installed_status(
app_version: app.version,
permissions: app.permissions,
origin,
local_override: metadata.local_override,
local_override,
}));
}
}
Ok(None)
}

/// Marketplace origins of every installed MiniApp, keyed by local app id.
///
/// The local `MiniApp::version` counter tracks edits to the installed copy and
/// is deliberately independent from the marketplace release number, so any
/// surface that wants to name the release a user installed has to read it from
/// the origin instead of from the app itself.
#[tauri::command]
pub async fn miniapp_market_installed_origins(
state: State<'_, AppState>,
) -> Result<HashMap<String, InstalledMarketOrigin>, String> {
let apps = state
.miniapp_manager
.list()
.await
.map_err(|error| error.to_string())?;
let mut origins = HashMap::new();
for app in apps {
if let Some((origin, _)) = load_market_origin(&state, &app.id).await? {
origins.insert(app.id, origin);
}
}
Ok(origins)
}

/// Reads an app's marketplace origin plus its local-override flag, or `None`
/// when the app did not come from the marketplace.
async fn load_market_origin(
state: &AppState,
app_id: &str,
) -> Result<Option<(InstalledMarketOrigin, bool)>, String> {
let Some(metadata) = state
.miniapp_manager
.load_customization_metadata(app_id)
.await
.map_err(|error| error.to_string())?
else {
return Ok(None);
};
let local_override = metadata.local_override;
Ok(metadata
.origin
.market
.map(|origin| (origin, local_override)))
}

#[tauri::command]
pub async fn miniapp_market_install(
state: State<'_, AppState>,
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LocalOnly,
),
("miniapp_market_install", RemoteWorkspacePolicy::LocalOnly),
(
"miniapp_market_installed_origins",
RemoteWorkspacePolicy::LocalOnly,
),
(
"miniapp_market_installed_status",
RemoteWorkspacePolicy::LocalOnly,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,7 @@ pub async fn run() {
api::miniapp_market_api::miniapp_market_list_submissions,
api::miniapp_market_api::miniapp_market_withdraw_submission,
api::miniapp_market_api::miniapp_market_installed_status,
api::miniapp_market_api::miniapp_market_installed_origins,
api::miniapp_market_api::miniapp_market_install,
api::miniapp_market_api::miniapp_market_import_package,
api::miniapp_market_api::miniapp_market_inspect_package,
Expand Down
10 changes: 9 additions & 1 deletion src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ interface MiniAppCardProps {
index?: number;
isRunning?: boolean;
isCustomizing?: boolean;
/**
* Marketplace release this copy was installed from, when it came from the
* marketplace. It takes over the version label because `app.version` is a
* local edit counter that always starts at 1 — showing it made a freshly
* installed v2 read as "v1".
*/
marketReleaseNumber?: number;
onOpenDetails: (app: MiniAppMeta) => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
Expand All @@ -26,6 +33,7 @@ const MiniAppCard: React.FC<MiniAppCardProps> = ({
index = 0,
isRunning = false,
isCustomizing = false,
marketReleaseNumber,
onOpenDetails,
onOpen,
onDelete,
Expand Down Expand Up @@ -82,7 +90,7 @@ const MiniAppCard: React.FC<MiniAppCardProps> = ({
</div>
<div className="miniapp-card__title-group">
<span className="miniapp-card__name">{localizedName}</span>
<span className="miniapp-card__version">v{app.version}</span>
<span className="miniapp-card__version">v{marketReleaseNumber ?? app.version}</span>
</div>
{(isRunning || isCustomizing) && (
<span className="miniapp-card__status-dots" aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI';
import { BackgroundTaskCancelledError } from '@/shared/utils/backgroundTaskScheduler';
import { createLogger } from '@/shared/utils/logger';
import { useMiniAppStore } from '../miniAppStore';
import { loadInstalledMarketOrigins } from '../utils/loadInstalledMarketOrigins';
import { scheduleMiniAppCatalogStartupRefresh } from './miniAppCatalogStartupRefresh';

const log = createLogger('useMiniAppCatalogSync');
Expand All @@ -20,21 +21,26 @@ export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {}
const { enabled = true, initialLoad = 'immediate' } = options;
const setApps = useMiniAppStore((state) => state.setApps);
const setLoading = useMiniAppStore((state) => state.setLoading);
const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins);
const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds);
const markWorkerRunning = useMiniAppStore((state) => state.markWorkerRunning);
const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped);

const refreshApps = useCallback(async () => {
setLoading(true);
try {
const apps = await miniAppAPI.listMiniApps();
const [apps, origins] = await Promise.all([
miniAppAPI.listMiniApps(),
loadInstalledMarketOrigins(),
]);
setApps(apps);
setMarketOrigins(origins);
} catch (error) {
log.error('Failed to load miniapps', error);
} finally {
setLoading(false);
}
}, [setApps, setLoading]);
}, [setApps, setLoading, setMarketOrigins]);

const refreshRunningWorkers = useCallback(async () => {
try {
Expand Down
29 changes: 29 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/miniAppStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('miniAppStore customization state', () => {
runningWorkerIds: [],
customizingAppIds: [],
composerClaims: {},
marketOrigins: {},
});
});

Expand Down Expand Up @@ -54,6 +55,34 @@ describe('miniAppStore customization state', () => {
expect(useMiniAppStore.getState().runningWorkerIds).toEqual(['gomoku']);
});

it('keeps market origins only for apps still in the catalog', () => {
const origin = {
listingId: 'listing-1',
releaseId: 'release-2',
releaseNumber: 2,
packageSha256: 'a'.repeat(64),
};
useMiniAppStore.getState().setMarketOrigin('gomoku', origin);
useMiniAppStore.getState().setMarketOrigin('removed-app', origin);

useMiniAppStore.getState().setApps([
{
id: 'gomoku',
name: 'Gomoku',
description: '',
category: 'game',
version: 1,
icon: 'box',
tags: [],
created_at: 1,
updated_at: 1,
permissions: {},
},
]);

expect(useMiniAppStore.getState().marketOrigins).toEqual({ gomoku: origin });
});

it('adds and updates an installed app without waiting for a catalog reload', () => {
const installed = {
id: 'market-app',
Expand Down
18 changes: 18 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/miniAppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { create } from 'zustand';
import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI';
import type { InstalledMarketOrigin } from '@/infrastructure/api/service-api/MiniAppMarketAPI';

/**
* Window event dispatched after the shared ChatInput routes a submission to a
Expand Down Expand Up @@ -146,10 +147,20 @@ interface MiniAppState {
customizingAppIds: string[];
/** Floating bubble registrations, keyed by app ID (`app.chat.claimComposer`). */
composerClaims: Record<string, MiniAppComposerClaim>;
/**
* Marketplace origin of each installed app, keyed by app ID. Apps installed
* from anywhere else are simply absent. The local `version` counter on a
* MiniApp is independent from the marketplace release number, so this is what
* the gallery reads to name the release a user actually installed.
*/
marketOrigins: Record<string, InstalledMarketOrigin>;

setApps: (apps: MiniAppMeta[]) => void;
upsertApp: (app: MiniAppMeta) => void;
setLoading: (loading: boolean) => void;
setMarketOrigins: (origins: Record<string, InstalledMarketOrigin>) => void;
/** Records the origin of a single app right after a market install/update. */
setMarketOrigin: (appId: string, origin: InstalledMarketOrigin) => void;
openApp: (id: string) => void;
closeApp: (id: string) => void;
setRunningWorkerIds: (ids: string[]) => void;
Expand All @@ -173,6 +184,7 @@ export const useMiniAppStore = create<MiniAppState>((set) => ({
runningWorkerIds: [],
customizingAppIds: [],
composerClaims: {},
marketOrigins: {},

setApps: (apps) =>
set((state) => {
Expand All @@ -185,6 +197,9 @@ export const useMiniAppStore = create<MiniAppState>((set) => ({
composerClaims: Object.fromEntries(
Object.entries(state.composerClaims).filter(([id]) => validIds.has(id))
),
marketOrigins: Object.fromEntries(
Object.entries(state.marketOrigins).filter(([id]) => validIds.has(id))
),
};
}),
upsertApp: (app) =>
Expand All @@ -198,6 +213,9 @@ export const useMiniAppStore = create<MiniAppState>((set) => ({
return { apps };
}),
setLoading: (loading) => set({ loading }),
setMarketOrigins: (marketOrigins) => set({ marketOrigins }),
setMarketOrigin: (appId, origin) =>
set((state) => ({ marketOrigins: { ...state.marketOrigins, [appId]: origin } })),

openApp: (id) =>
set((state) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
miniAppMarketAPI,
type InstalledMarketOrigin,
} from '@/infrastructure/api/service-api/MiniAppMarketAPI';
import { createLogger } from '@/shared/utils/logger';

const log = createLogger('installedMarketOrigins');

/**
* Marketplace origins for the installed catalog, keyed by app id.
*
* Every catalog load pairs with this so version labels can name the release a
* user installed instead of the local edit counter. A failure degrades those
* labels back to the local counter rather than taking the catalog down with it.
*/
export async function loadInstalledMarketOrigins(): Promise<
Record<string, InstalledMarketOrigin>
> {
try {
return await miniAppMarketAPI.installedOrigins();
} catch (error) {
log.warn('Failed to read installed MiniApp market origins', error);
return {};
}
}
15 changes: 12 additions & 3 deletions src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from '@/app/components';
import type { SceneTabId } from '@/app/components/SceneBar/types';
import { getMiniAppIconGradient, renderMiniAppIcon } from '../utils/miniAppIcons';
import { loadInstalledMarketOrigins } from '../utils/loadInstalledMarketOrigins';
import { pickLocalizedString, pickLocalizedTags } from '../utils/pickLocalizedString';
import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext';
import { useMiniAppStore } from '../miniAppStore';
Expand All @@ -47,8 +48,10 @@ const MiniAppGalleryView: React.FC = () => {
const loading = useMiniAppStore((state) => state.loading);
const runningWorkerIds = useMiniAppStore((state) => state.runningWorkerIds);
const customizingAppIds = useMiniAppStore((state) => state.customizingAppIds);
const marketOrigins = useMiniAppStore((state) => state.marketOrigins);
const setApps = useMiniAppStore((state) => state.setApps);
const setLoading = useMiniAppStore((state) => state.setLoading);
const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins);
const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds);
const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped);
const { workspacePath } = useCurrentWorkspace();
Expand Down Expand Up @@ -160,18 +163,20 @@ const MiniAppGalleryView: React.FC = () => {
const refetchMiniAppGallery = useCallback(async () => {
setLoading(true);
try {
const [refreshed, running] = await Promise.all([
const [refreshed, running, origins] = await Promise.all([
miniAppAPI.listMiniApps(),
miniAppAPI.workerListRunning(),
loadInstalledMarketOrigins(),
]);
setApps(refreshed);
setRunningWorkerIds(running);
setMarketOrigins(origins);
} catch (error) {
log.error('Failed to refresh miniapp gallery', error);
} finally {
setLoading(false);
}
}, [setApps, setLoading, setRunningWorkerIds]);
}, [setApps, setLoading, setMarketOrigins, setRunningWorkerIds]);

useGallerySceneAutoRefresh({
sceneId: 'miniapps',
Expand Down Expand Up @@ -294,6 +299,7 @@ const MiniAppGalleryView: React.FC = () => {
index={index}
isRunning={runningIdSet.has(app.id)}
isCustomizing={customizingIdSet.has(app.id)}
marketReleaseNumber={marketOrigins[app.id]?.releaseNumber}
onOpenDetails={setSelectedApp}
onOpen={handleOpenApp}
onDelete={handleDeleteRequest}
Expand Down Expand Up @@ -347,6 +353,7 @@ const MiniAppGalleryView: React.FC = () => {
index={index}
isRunning
isCustomizing={customizingIdSet.has(app.id)}
marketReleaseNumber={marketOrigins[app.id]?.releaseNumber}
onOpenDetails={setSelectedApp}
onOpen={handleOpenApp}
onDelete={handleDeleteRequest}
Expand Down Expand Up @@ -400,7 +407,9 @@ const MiniAppGalleryView: React.FC = () => {
title={selectedApp ? pickLocalizedString(selectedApp, currentLanguage, 'name') : ''}
badges={selectedApp?.category ? <Badge variant="info">{selectedApp.category}</Badge> : null}
description={selectedApp ? pickLocalizedString(selectedApp, currentLanguage, 'description') : undefined}
meta={selectedApp ? <span>v{selectedApp.version}</span> : null}
meta={selectedApp ? (
<span>v{marketOrigins[selectedApp.id]?.releaseNumber ?? selectedApp.version}</span>
) : null}
actions={selectedApp ? (
<>
{runningIdSet.has(selectedApp.id) ? (
Expand Down
17 changes: 17 additions & 0 deletions src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@
font-size: var(--font-size-sm);
}
}

&__releases-toggle {
display: inline-flex;
align-items: center;
gap: $size-gap-1;
margin-top: $size-gap-2;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
cursor: pointer;

&:hover {
color: var(--color-text-secondary);
}
}
}

@media (max-width: 720px) {
Expand Down
Loading
Loading