348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { createStore, type StoreApi } from 'zustand';
|
|
import { useStore } from 'zustand';
|
|
import {
|
|
acquireMarketplacePlugin,
|
|
installBetaMarketplacePlugin,
|
|
installMarketplacePlugin,
|
|
readMarketplaceCatalog,
|
|
readMarketplaceDetail,
|
|
readMarketplaceLibrary,
|
|
removeMarketplacePlugin,
|
|
uninstallMarketplacePlugin,
|
|
updateMarketplacePlugin,
|
|
type MarketplaceCatalogPage,
|
|
type MarketplaceCatalogQuery,
|
|
type MarketplaceInstallation,
|
|
type MarketplaceLibraryProjection,
|
|
type MarketplaceLibrarySnapshot,
|
|
type MarketplacePluginDetail,
|
|
} from '@/lib/plugin-marketplace';
|
|
|
|
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
|
|
|
export type PluginMarketplaceDependencies = {
|
|
readCatalog(input?: MarketplaceCatalogQuery): Promise<MarketplaceCatalogPage>;
|
|
readDetail(pluginId: string): Promise<MarketplacePluginDetail>;
|
|
readLibrary(): Promise<MarketplaceLibraryProjection>;
|
|
acquire(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
|
remove(pluginId: string): Promise<MarketplaceLibraryProjection>;
|
|
install(pluginId: string): Promise<MarketplaceInstallation>;
|
|
installBeta(pluginId: string): Promise<MarketplaceInstallation>;
|
|
update(pluginId: string): Promise<MarketplaceInstallation>;
|
|
uninstall(pluginId: string): Promise<MarketplaceInstallation>;
|
|
};
|
|
|
|
export type PluginMarketplaceState = {
|
|
accountKey: string | null;
|
|
catalog: MarketplaceCatalogPage | null;
|
|
catalogState: LoadState;
|
|
catalogError: string | null;
|
|
catalogQuery: MarketplaceCatalogQuery;
|
|
library: MarketplaceLibrarySnapshot | null;
|
|
libraryState: LoadState;
|
|
libraryError: string | null;
|
|
installations: Record<string, MarketplaceInstallation>;
|
|
details: Record<string, MarketplacePluginDetail>;
|
|
detailState: Record<string, LoadState>;
|
|
pending: Record<string, true>;
|
|
activateAccount(accountKey: string | null): void;
|
|
loadCatalog(input?: MarketplaceCatalogQuery): Promise<void>;
|
|
loadDetail(pluginId: string): Promise<void>;
|
|
loadLibrary(): Promise<void>;
|
|
acquire(pluginId: string): Promise<void>;
|
|
remove(pluginId: string): Promise<void>;
|
|
install(pluginId: string): Promise<void>;
|
|
installBeta(pluginId: string): Promise<void>;
|
|
update(pluginId: string): Promise<void>;
|
|
uninstall(pluginId: string): Promise<void>;
|
|
};
|
|
|
|
const defaults: PluginMarketplaceDependencies = {
|
|
readCatalog: readMarketplaceCatalog,
|
|
readDetail: readMarketplaceDetail,
|
|
readLibrary: readMarketplaceLibrary,
|
|
acquire: acquireMarketplacePlugin,
|
|
remove: removeMarketplacePlugin,
|
|
install: installMarketplacePlugin,
|
|
installBeta: installBetaMarketplacePlugin,
|
|
update: updateMarketplacePlugin,
|
|
uninstall: uninstallMarketplacePlugin,
|
|
};
|
|
|
|
function message(error: unknown): string {
|
|
const base = error instanceof Error ? error.message : String(error);
|
|
if (!error || typeof error !== 'object') return base;
|
|
const errorCode = 'code' in error && typeof error.code === 'string' ? error.code : null;
|
|
const details = 'details' in error && error.details && typeof error.details === 'object'
|
|
? error.details as Record<string, unknown>
|
|
: null;
|
|
const backendCode = details?.backendCode;
|
|
const prefixed = typeof backendCode === 'string' ? `${backendCode}: ${base}` : base;
|
|
return errorCode && !prefixed.includes(errorCode) ? `${errorCode}: ${prefixed}` : prefixed;
|
|
}
|
|
|
|
function installationMap(items: readonly MarketplaceInstallation[]): Record<string, MarketplaceInstallation> {
|
|
return Object.fromEntries(items.map((item) => [item.pluginId, item]));
|
|
}
|
|
|
|
export function createPluginMarketplaceStore(
|
|
overrides: Partial<PluginMarketplaceDependencies> = {},
|
|
): StoreApi<PluginMarketplaceState> {
|
|
const deps = { ...defaults, ...overrides };
|
|
let scopeGeneration = 0;
|
|
let catalogGeneration = 0;
|
|
let libraryIntent = 0;
|
|
let latestLibraryReadIntent = 0;
|
|
const latestLibraryMutationIntent = new Map<string, number>();
|
|
const latestDeviceMutationIntent = new Map<string, number>();
|
|
|
|
return createStore<PluginMarketplaceState>((set, get) => {
|
|
const beginMutation = (pluginId: string, domain: 'library' | 'device'): number => {
|
|
const intent = ++libraryIntent;
|
|
(domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).set(pluginId, intent);
|
|
return intent;
|
|
};
|
|
|
|
const canCommitMutation = (
|
|
pluginId: string,
|
|
intent: number,
|
|
generation: number,
|
|
accountKey: string,
|
|
domain: 'library' | 'device',
|
|
): boolean => generation === scopeGeneration
|
|
&& get().accountKey === accountKey
|
|
&& (domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).get(pluginId) === intent
|
|
// A later explicit read is the newest Library projection, so an older
|
|
// Library mutation must not replace it. Device state is independent.
|
|
&& (domain === 'device' || latestLibraryReadIntent <= intent);
|
|
|
|
const latestPluginMutationIntent = (pluginId: string): number => Math.max(
|
|
latestLibraryMutationIntent.get(pluginId) ?? 0,
|
|
latestDeviceMutationIntent.get(pluginId) ?? 0,
|
|
);
|
|
|
|
const mergeLibraryForPlugin = (
|
|
current: MarketplaceLibrarySnapshot | null,
|
|
incoming: MarketplaceLibrarySnapshot,
|
|
pluginId: string,
|
|
): MarketplaceLibrarySnapshot => {
|
|
if (!current) return incoming;
|
|
const byPlugin = new Map(current.items.map((item) => [item.pluginId, item]));
|
|
const incomingItem = incoming.items.find((item) => item.pluginId === pluginId);
|
|
if (incomingItem) byPlugin.set(pluginId, incomingItem);
|
|
else byPlugin.delete(pluginId);
|
|
const items = [...byPlugin.values()].sort((left, right) => {
|
|
const leftIntent = latestPluginMutationIntent(left.pluginId);
|
|
const rightIntent = latestPluginMutationIntent(right.pluginId);
|
|
return rightIntent - leftIntent;
|
|
});
|
|
return {
|
|
...incoming,
|
|
items,
|
|
total: Math.max(current.total, incoming.total, items.length),
|
|
fetchedAt: Math.max(current.fetchedAt, incoming.fetchedAt),
|
|
};
|
|
};
|
|
|
|
const mergeLibraryProjection = (
|
|
pluginId: string,
|
|
projection: MarketplaceLibraryProjection,
|
|
): Pick<PluginMarketplaceState, 'library' | 'installations'> => {
|
|
const state = get();
|
|
const library = mergeLibraryForPlugin(state.library, projection.library, pluginId);
|
|
const incomingInstallation = projection.installations.find((item) => item.pluginId === pluginId);
|
|
const installations = { ...state.installations };
|
|
if (incomingInstallation) installations[pluginId] = incomingInstallation;
|
|
return { library, installations };
|
|
};
|
|
|
|
const pending = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
|
|
set((state) => ({ pending: { ...state.pending, [key]: true } }));
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
set((state) => {
|
|
const next = { ...state.pending };
|
|
delete next[key];
|
|
return { pending: next };
|
|
});
|
|
}
|
|
};
|
|
|
|
const libraryAction = async (
|
|
key: string,
|
|
pluginId: string,
|
|
action: (pluginId: string) => Promise<MarketplaceLibraryProjection>,
|
|
): Promise<void> => pending(key, async () => {
|
|
const generation = scopeGeneration;
|
|
const accountKey = get().accountKey;
|
|
if (!accountKey) throw new Error('请先登录后管理我的插件');
|
|
const intent = beginMutation(pluginId, 'library');
|
|
try {
|
|
const projection = await action(pluginId);
|
|
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'library')) return;
|
|
const merged = mergeLibraryProjection(pluginId, projection);
|
|
set({
|
|
library: merged.library,
|
|
installations: merged.installations,
|
|
libraryState: 'ready',
|
|
libraryError: null,
|
|
});
|
|
} catch (error) {
|
|
if (canCommitMutation(pluginId, intent, generation, accountKey, 'library')) {
|
|
set({ libraryError: message(error) });
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
const installAction = async (
|
|
key: string,
|
|
pluginId: string,
|
|
action: (pluginId: string) => Promise<MarketplaceInstallation>,
|
|
): Promise<void> => pending(key, async () => {
|
|
const generation = scopeGeneration;
|
|
const accountKey = get().accountKey;
|
|
if (!accountKey) throw new Error('请先登录后管理设备插件');
|
|
const intent = beginMutation(pluginId, 'device');
|
|
try {
|
|
const result = await action(pluginId);
|
|
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'device')) return;
|
|
set((state) => {
|
|
const installations = { ...state.installations };
|
|
if (result.status === 'removed') delete installations[pluginId];
|
|
else installations[pluginId] = result;
|
|
return { installations, libraryError: null };
|
|
});
|
|
} catch (error) {
|
|
if (canCommitMutation(pluginId, intent, generation, accountKey, 'device')) {
|
|
const reason = message(error);
|
|
set((state) => ({
|
|
installations: {
|
|
...state.installations,
|
|
[pluginId]: {
|
|
status: 'unavailable',
|
|
pluginId,
|
|
...(state.installations[pluginId]?.releaseId
|
|
? { releaseId: state.installations[pluginId].releaseId }
|
|
: {}),
|
|
...(state.installations[pluginId]?.version
|
|
? { version: state.installations[pluginId].version }
|
|
: {}),
|
|
reason,
|
|
},
|
|
},
|
|
libraryError: reason,
|
|
}));
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
return {
|
|
accountKey: null,
|
|
catalog: null,
|
|
catalogState: 'idle',
|
|
catalogError: null,
|
|
catalogQuery: {},
|
|
library: null,
|
|
libraryState: 'idle',
|
|
libraryError: null,
|
|
installations: {},
|
|
details: {},
|
|
detailState: {},
|
|
pending: {},
|
|
activateAccount(accountKey) {
|
|
if (get().accountKey === accountKey) return;
|
|
scopeGeneration += 1;
|
|
libraryIntent += 1;
|
|
latestLibraryReadIntent = libraryIntent;
|
|
latestLibraryMutationIntent.clear();
|
|
latestDeviceMutationIntent.clear();
|
|
set({
|
|
accountKey,
|
|
library: null,
|
|
libraryState: 'idle',
|
|
libraryError: null,
|
|
installations: {},
|
|
pending: {},
|
|
});
|
|
},
|
|
async loadCatalog(input = {}) {
|
|
const generation = ++catalogGeneration;
|
|
set({ catalogState: 'loading', catalogError: null, catalogQuery: input });
|
|
try {
|
|
const catalog = await deps.readCatalog(input);
|
|
if (generation === catalogGeneration) set({ catalog, catalogState: 'ready', catalogError: null });
|
|
} catch (error) {
|
|
if (generation === catalogGeneration) set({ catalogState: 'error', catalogError: message(error) });
|
|
throw error;
|
|
}
|
|
},
|
|
async loadDetail(pluginId) {
|
|
const generation = scopeGeneration;
|
|
set((state) => ({ detailState: { ...state.detailState, [pluginId]: 'loading' } }));
|
|
try {
|
|
const detail = await deps.readDetail(pluginId);
|
|
if (generation !== scopeGeneration) return;
|
|
set((state) => ({
|
|
details: { ...state.details, [pluginId]: detail },
|
|
detailState: { ...state.detailState, [pluginId]: 'ready' },
|
|
}));
|
|
} catch (error) {
|
|
if (generation === scopeGeneration) {
|
|
set((state) => ({
|
|
detailState: { ...state.detailState, [pluginId]: 'error' },
|
|
catalogError: message(error),
|
|
}));
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
async loadLibrary() {
|
|
const readIntent = ++libraryIntent;
|
|
latestLibraryReadIntent = readIntent;
|
|
const generation = scopeGeneration;
|
|
const accountKey = get().accountKey;
|
|
if (!accountKey) {
|
|
set({ library: null, installations: {}, libraryState: 'idle', libraryError: null });
|
|
return;
|
|
}
|
|
set({ libraryState: 'loading', libraryError: null });
|
|
try {
|
|
const projection = await deps.readLibrary();
|
|
if (generation === scopeGeneration && get().accountKey === accountKey
|
|
&& readIntent === latestLibraryReadIntent
|
|
&& readIntent === libraryIntent) {
|
|
set({
|
|
library: projection.library,
|
|
installations: installationMap(projection.installations),
|
|
libraryState: 'ready',
|
|
libraryError: null,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (generation === scopeGeneration && get().accountKey === accountKey
|
|
&& readIntent === latestLibraryReadIntent
|
|
&& readIntent === libraryIntent) {
|
|
set({ libraryState: 'error', libraryError: message(error) });
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire),
|
|
remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove),
|
|
install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install),
|
|
installBeta: (pluginId) => installAction(`install-beta:${pluginId}`, pluginId, deps.installBeta),
|
|
update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update),
|
|
uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall),
|
|
};
|
|
});
|
|
}
|
|
|
|
export const pluginMarketplaceStore = createPluginMarketplaceStore();
|
|
|
|
export function usePluginMarketplaceStore<T>(selector: (state: PluginMarketplaceState) => T): T {
|
|
return useStore(pluginMarketplaceStore, selector);
|
|
}
|