fix: complete marketplace client remediation
This commit is contained in:
@@ -89,6 +89,7 @@ export type MarketplaceInstallation = {
|
||||
pluginId: string;
|
||||
releaseId?: string;
|
||||
version?: string;
|
||||
channel?: 'stable' | 'beta';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
@@ -198,6 +199,7 @@ function installation(value: unknown): MarketplaceInstallation {
|
||||
pluginId: pluginId(text(item.pluginId, 'installation.pluginId', 128)),
|
||||
...(item.releaseId === undefined ? {} : { releaseId: text(item.releaseId, 'installation.releaseId', 128) }),
|
||||
...(item.version === undefined ? {} : { version: text(item.version, 'installation.version', 64) }),
|
||||
...(item.channel === undefined ? {} : { channel: oneOf(item.channel, ['stable', 'beta'] as const, 'installation.channel') }),
|
||||
...(item.reason === undefined ? {} : { reason: text(item.reason, 'installation.reason', 256) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,18 +67,21 @@ export function MyPluginsView(props: MyPluginsViewProps) {
|
||||
const removed = plugin.removedAt !== null;
|
||||
const suspended = plugin.runtimeStatus === 'suspended';
|
||||
const retired = plugin.catalogStatus === 'retired';
|
||||
const updateAvailable = Boolean(installed?.version && plugin.stableVersion && installed.version !== plugin.stableVersion);
|
||||
const installedChannel = installed?.channel ?? 'stable';
|
||||
const channelVersion = installedChannel === 'beta' ? plugin.betaVersion : plugin.stableVersion;
|
||||
const updateAvailable = Boolean(installed?.version && channelVersion && installed.version !== channelVersion);
|
||||
const betaChannelUnavailable = Boolean(installed && installedChannel === 'beta' && !plugin.betaVersion);
|
||||
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
|
||||
const failure = failureText(installed?.reason);
|
||||
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : removed ? <Badge variant="outline">已移除</Badge> : <Badge variant="secondary">已获取</Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums">稳定版 {plugin.stableVersion ?? '不可用'}</p>{installed?.version ? <p className="mt-1 tabular-nums text-muted-foreground">设备版本 {installed.version}</p> : <p className="mt-1 text-muted-foreground">尚未下载到设备</p>}</div></div>
|
||||
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive">运行已暂停</p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline">系统内置</Badge> : removed ? <Badge variant="outline">已移除</Badge> : <Badge variant="secondary">已获取</Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums">稳定版 {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta 版 {plugin.betaVersion}</p> : null}{installed?.version ? <><p className="mt-1 tabular-nums text-muted-foreground">设备版本 {installed.version}</p><p className="mt-1 text-muted-foreground">当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground">尚未下载到设备</p>}</div></div>
|
||||
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive">运行已暂停</p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{betaChannelUnavailable ? <p>当前 Beta 频道暂无可用版本;不会静默切回稳定版。</p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}>重新免费获取</Button>
|
||||
: !installed ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" />下载</Button>
|
||||
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`更新${plugin.title}`} onClick={() => void props.onUpdate(plugin.pluginId)}><RefreshCw className="mr-2 h-4 w-4" />更新</Button>
|
||||
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`${installedChannel === 'beta' ? '更新 Beta' : '更新'}${plugin.title}`} onClick={() => void (installedChannel === 'beta' ? props.onInstallBeta(plugin.pluginId) : props.onUpdate(plugin.pluginId))}><RefreshCw className="mr-2 h-4 w-4" />{installedChannel === 'beta' ? '更新 Beta' : '更新'}</Button>
|
||||
: <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" />设备版本已是最新</span>}
|
||||
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}>安装 Beta</Button> : null}
|
||||
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}>安装 Beta</Button> : null}
|
||||
{!removed && plugin.acquisition !== 'system_included' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy} aria-label={`移除${plugin.title}`} onClick={() => void props.onRemove(plugin.pluginId)}><Trash2 className="mr-2 h-4 w-4" />移除</Button> : null}
|
||||
{installed ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}>删除设备包</Button> : null}
|
||||
<Button asChild variant="outline" className="min-h-10"><Link to="/project-plugins">启用到项目</Link></Button>
|
||||
|
||||
@@ -91,10 +91,64 @@ export function createPluginMarketplaceStore(
|
||||
const deps = { ...defaults, ...overrides };
|
||||
let scopeGeneration = 0;
|
||||
let catalogGeneration = 0;
|
||||
let libraryMutationEpoch = 0;
|
||||
let libraryReadGeneration = 0;
|
||||
let libraryIntent = 0;
|
||||
let latestLibraryReadIntent = 0;
|
||||
const latestPluginMutationIntent = new Map<string, number>();
|
||||
|
||||
return createStore<PluginMarketplaceState>((set, get) => {
|
||||
const beginMutation = (pluginId: string): number => {
|
||||
const intent = ++libraryIntent;
|
||||
latestPluginMutationIntent.set(pluginId, intent);
|
||||
return intent;
|
||||
};
|
||||
|
||||
const canCommitMutation = (
|
||||
pluginId: string,
|
||||
intent: number,
|
||||
generation: number,
|
||||
accountKey: string,
|
||||
): boolean => generation === scopeGeneration
|
||||
&& get().accountKey === accountKey
|
||||
&& latestPluginMutationIntent.get(pluginId) === intent
|
||||
// A later explicit read is the newest account projection for every
|
||||
// plugin, so an older mutation must not replace it.
|
||||
&& latestLibraryReadIntent <= intent;
|
||||
|
||||
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.get(left.pluginId) ?? 0;
|
||||
const rightIntent = latestPluginMutationIntent.get(right.pluginId) ?? 0;
|
||||
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 {
|
||||
@@ -116,21 +170,19 @@ export function createPluginMarketplaceStore(
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理我的插件');
|
||||
const mutationEpoch = ++libraryMutationEpoch;
|
||||
const intent = beginMutation(pluginId);
|
||||
try {
|
||||
const projection = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey
|
||||
|| mutationEpoch !== libraryMutationEpoch) return;
|
||||
libraryMutationEpoch += 1;
|
||||
if (!canCommitMutation(pluginId, intent, generation, accountKey)) return;
|
||||
const merged = mergeLibraryProjection(pluginId, projection);
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
library: merged.library,
|
||||
installations: merged.installations,
|
||||
libraryState: 'ready',
|
||||
libraryError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
if (canCommitMutation(pluginId, intent, generation, accountKey)) {
|
||||
set({ libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
@@ -145,12 +197,10 @@ export function createPluginMarketplaceStore(
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
if (!accountKey) throw new Error('请先登录后管理设备插件');
|
||||
const mutationEpoch = ++libraryMutationEpoch;
|
||||
const intent = beginMutation(pluginId);
|
||||
try {
|
||||
const result = await action(pluginId);
|
||||
if (generation !== scopeGeneration || get().accountKey !== accountKey
|
||||
|| mutationEpoch !== libraryMutationEpoch) return;
|
||||
libraryMutationEpoch += 1;
|
||||
if (!canCommitMutation(pluginId, intent, generation, accountKey)) return;
|
||||
set((state) => {
|
||||
const installations = { ...state.installations };
|
||||
if (result.status === 'removed') delete installations[pluginId];
|
||||
@@ -158,8 +208,7 @@ export function createPluginMarketplaceStore(
|
||||
return { installations, libraryError: null };
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
if (canCommitMutation(pluginId, intent, generation, accountKey)) {
|
||||
const reason = message(error);
|
||||
set((state) => ({
|
||||
installations: {
|
||||
@@ -199,8 +248,9 @@ export function createPluginMarketplaceStore(
|
||||
activateAccount(accountKey) {
|
||||
if (get().accountKey === accountKey) return;
|
||||
scopeGeneration += 1;
|
||||
libraryReadGeneration += 1;
|
||||
libraryMutationEpoch += 1;
|
||||
libraryIntent += 1;
|
||||
latestLibraryReadIntent = libraryIntent;
|
||||
latestPluginMutationIntent.clear();
|
||||
set({
|
||||
accountKey,
|
||||
library: null,
|
||||
@@ -242,10 +292,10 @@ export function createPluginMarketplaceStore(
|
||||
}
|
||||
},
|
||||
async loadLibrary() {
|
||||
const readGeneration = ++libraryReadGeneration;
|
||||
const readIntent = ++libraryIntent;
|
||||
latestLibraryReadIntent = readIntent;
|
||||
const generation = scopeGeneration;
|
||||
const accountKey = get().accountKey;
|
||||
const mutationEpoch = libraryMutationEpoch;
|
||||
if (!accountKey) {
|
||||
set({ library: null, installations: {}, libraryState: 'idle', libraryError: null });
|
||||
return;
|
||||
@@ -254,8 +304,8 @@ export function createPluginMarketplaceStore(
|
||||
try {
|
||||
const projection = await deps.readLibrary();
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& readGeneration === libraryReadGeneration
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
&& readIntent === latestLibraryReadIntent
|
||||
&& readIntent === libraryIntent) {
|
||||
set({
|
||||
library: projection.library,
|
||||
installations: installationMap(projection.installations),
|
||||
@@ -265,8 +315,8 @@ export function createPluginMarketplaceStore(
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === scopeGeneration && get().accountKey === accountKey
|
||||
&& readGeneration === libraryReadGeneration
|
||||
&& mutationEpoch === libraryMutationEpoch) {
|
||||
&& readIntent === latestLibraryReadIntent
|
||||
&& readIntent === libraryIntent) {
|
||||
set({ libraryState: 'error', libraryError: message(error) });
|
||||
}
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user