fix(marketplace): close release lifecycle gaps
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
# Task: Marketplace MLM-06 R6 fixed-range remediation
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260830-marketplace-mlm06-r6-remediation-3c8e7a52
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260830-marketplace-mlm06-r6-remediation-3c8e7a52-marketplace-mlm06-r6-remediation
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r6-remediation-3c8e7a52
|
||||||
|
- Base commit: 8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764
|
||||||
|
- Owner: marketplace-client-coordinator-r6-remediator
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Remediate the three deduplicated R6 client review findings from exact clean
|
||||||
|
coordinator frontier `8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764`.
|
||||||
|
- Own only Package Store channel/deferred-cleanup behavior, the Main-to-Pi active
|
||||||
|
Release disposal seam, the My Plugins system-included projection, focused tests,
|
||||||
|
and this task record.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Preserve immutable Release bytes and frozen active workers while making explicit
|
||||||
|
uninstall complete automatically after the last protecting worker exits; any local
|
||||||
|
account snapshot reference must continue to block deletion.
|
||||||
|
- Persist the explicitly requested Stable/Beta channel when both channels resolve to
|
||||||
|
the same immutable Release, without another download or hot-swapping a worker.
|
||||||
|
- Present `system_included` as supplied with MakeLore and suppress device package
|
||||||
|
actions, while preserving project enablement and Agent assignment actions.
|
||||||
|
- Test first. Keep one source commit and one writer. Do not touch Server, user root
|
||||||
|
worktrees, Release B/Provider/hosted runtime/billing, production trust, XMA-01,
|
||||||
|
deployment, publication, push, or PR state.
|
||||||
|
|
||||||
|
## Project Context Loaded
|
||||||
|
|
||||||
|
Task context:
|
||||||
|
- Task ID: `20260830-marketplace-mlm06-r6-remediation-3c8e7a52`
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: `codex/20260830-marketplace-mlm06-r6-remediation-3c8e7a52-marketplace-mlm06-r6-remediation`
|
||||||
|
- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r6-remediation-3c8e7a52`
|
||||||
|
- Base commit: `8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764`
|
||||||
|
- Other active local tasks: the clean client coordinator and read-only R6 Standards/
|
||||||
|
Spec reviewers are the only relevant same-topic owners; historical owners are
|
||||||
|
completed/ready-for-integration.
|
||||||
|
- Overlap or semantic-conflict assessment: review overlap is intentional and read-only;
|
||||||
|
both axes independently report the same three roots. No concurrent product writer or
|
||||||
|
contradictory decision exists.
|
||||||
|
|
||||||
|
Read:
|
||||||
|
- `AGENTS.md`; `implement-spec`, `maintain-project-docs`, and `code-review` skills.
|
||||||
|
- Project entry/planning memory, positioning, current state, decisions, architecture,
|
||||||
|
domain/evidence/reflection/commitment/stale indexes, coordinator record, R6 review records.
|
||||||
|
- Canonical Marketplace implementation spec, ticket graph MLM-02/MLM-06, and accepted
|
||||||
|
design Package Store/channel/system-included sections.
|
||||||
|
|
||||||
|
Relevant understanding:
|
||||||
|
- Project goal: Release A has one safe Package Store/effective projection chain while
|
||||||
|
acquisition, installation, enablement, assignment, runtime, and billing remain separate.
|
||||||
|
- Current integrated focus: post-R5 client branch is clean; Server R7 is frozen/pass.
|
||||||
|
- Active task scope: three surgical client corrections only.
|
||||||
|
- Active constraints: active worker and account references are authorities; Beta is
|
||||||
|
explicit; bundled Data Service never enters Library/admission/download.
|
||||||
|
- Decisions affecting this task: Package Store cleanup only removes unreferenced Releases;
|
||||||
|
current workers never hot-swap; stable/Beta remain distinct user intent.
|
||||||
|
- Evidence/commitments: existing R5 full unit/typecheck/lint/build evidence is the baseline;
|
||||||
|
official production public key and XMA-01 remain holds.
|
||||||
|
- Files likely involved: `electron/coding-plugins/package-store.ts`,
|
||||||
|
`electron/api/coding-composition.ts`, `electron/coding-runtime/pi/runtime.ts`,
|
||||||
|
`src/pages/MyPlugins/index.tsx`, and their focused tests.
|
||||||
|
- Unknowns/stale docs/conflicts: shared canonical memory predates Marketplace but the exact
|
||||||
|
coordinator/spec/review records are authoritative; no semantic conflict remains.
|
||||||
|
|
||||||
|
Gate result:
|
||||||
|
- Passed.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add red Package Store/lifecycle tests for deferred explicit cleanup and same-Release
|
||||||
|
channel switching, plus a red My Plugins system-included projection test.
|
||||||
|
2. Implement the smallest Package Store, awaited worker-disposal, and Renderer changes.
|
||||||
|
3. Run focused/adjacent tests, typecheck, lint, Vite build, then one full unit run and
|
||||||
|
pressure case; update documentation gates and create one source commit.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Explicit device-package removal now records a bounded pending cleanup only when an
|
||||||
|
active worker protects the Release. The final worker disposal is awaited through the
|
||||||
|
Main-to-Pi lifecycle and retries the explicit cleanup; another local account snapshot
|
||||||
|
still protects the immutable package, and an explicit reinstall cancels the pending
|
||||||
|
removal intent.
|
||||||
|
- Stable/Beta selection now persists the explicitly requested channel even when resolve
|
||||||
|
returns `keep` or reuses the same cached immutable Release. It does not redownload or
|
||||||
|
hot-swap a running worker.
|
||||||
|
- My Plugins treats `system_included` Data Service as supplied with MakeLore: it exposes
|
||||||
|
neither download/update/device-delete nor package-failure copy, while retaining the
|
||||||
|
separate project-enable and Agent-assignment navigation.
|
||||||
|
- No Server, Release B, provider/hosted/billing, production trust, XMA-01, deployment,
|
||||||
|
publication, push, or PR state was changed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- TDD red/green boundaries:
|
||||||
|
- active-worker explicit uninstall initially retained the index after release; green
|
||||||
|
after deferred cleanup and awaited worker disposal;
|
||||||
|
- Stable-to-Beta same-Release `keep` and cached reuse initially returned/persisted
|
||||||
|
Stable; green with one download and requested-channel persistence;
|
||||||
|
- system-included Data Service initially rendered "尚未下载到设备" and a download
|
||||||
|
action; green with supplied-with-MakeLore projection and no device action.
|
||||||
|
- Added protections: another account snapshot still blocks deferred deletion; an
|
||||||
|
explicit reinstall cancels it; Pi worker stop does not settle before async release
|
||||||
|
cleanup finishes.
|
||||||
|
- Focused owned tests: 3 files, 51 passed.
|
||||||
|
- Adjacent Package Store/Main/Pi/Renderer regression: 10 files, 116 passed.
|
||||||
|
- Full unit suite: 208 files, 1812 passed, 2 skipped; pressure suite: 1 file, 1 passed.
|
||||||
|
- `pnpm run typecheck`: passed.
|
||||||
|
- Scoped ESLint: passed. Full `pnpm run lint:check`: 0 errors and the unchanged 5
|
||||||
|
out-of-scope warnings in Home/Makelore.
|
||||||
|
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility bundles; only
|
||||||
|
existing dynamic-import/chunk-size warnings were reported.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Integrate the sole source commit into the clean client coordinator, then run fresh
|
||||||
|
fixed-range Standards and Spec reviews before opening XMA-01.
|
||||||
|
- Official Ed25519 production public key remains an external production-activation hold.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- None recorded.
|
||||||
@@ -135,7 +135,7 @@ export function createCodingComposition(
|
|||||||
clientVersion: options.clientVersion ?? '2.0.0',
|
clientVersion: options.clientVersion ?? '2.0.0',
|
||||||
});
|
});
|
||||||
const activePluginReleaseCounts = new Map<string, number>();
|
const activePluginReleaseCounts = new Map<string, number>();
|
||||||
const registerActivePluginReleases = (releaseIds: readonly string[]): (() => void) => {
|
const registerActivePluginReleases = (releaseIds: readonly string[]): (() => Promise<void>) => {
|
||||||
const uniqueReleaseIds = [...new Set(releaseIds)];
|
const uniqueReleaseIds = [...new Set(releaseIds)];
|
||||||
for (const releaseId of uniqueReleaseIds) {
|
for (const releaseId of uniqueReleaseIds) {
|
||||||
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
||||||
@@ -143,18 +143,20 @@ export function createCodingComposition(
|
|||||||
activePluginReleaseCounts.set(releaseId, count + 1);
|
activePluginReleaseCounts.set(releaseId, count + 1);
|
||||||
}
|
}
|
||||||
let released = false;
|
let released = false;
|
||||||
return () => {
|
return async () => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
|
const cleanup: Array<Promise<void>> = [];
|
||||||
for (const releaseId of uniqueReleaseIds) {
|
for (const releaseId of uniqueReleaseIds) {
|
||||||
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
const count = activePluginReleaseCounts.get(releaseId) ?? 0;
|
||||||
if (count <= 1) {
|
if (count <= 1) {
|
||||||
activePluginReleaseCounts.delete(releaseId);
|
activePluginReleaseCounts.delete(releaseId);
|
||||||
packageStore.releaseActiveWorker(releaseId);
|
cleanup.push(packageStore.releaseActiveWorker(releaseId));
|
||||||
} else {
|
} else {
|
||||||
activePluginReleaseCounts.set(releaseId, count - 1);
|
activePluginReleaseCounts.set(releaseId, count - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await Promise.all(cleanup);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let effectiveResolver: EffectivePluginResolver | undefined;
|
let effectiveResolver: EffectivePluginResolver | undefined;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export interface InstalledReleaseRecord {
|
|||||||
readonly sha256: string;
|
readonly sha256: string;
|
||||||
readonly sizeBytes: number;
|
readonly sizeBytes: number;
|
||||||
readonly installedAt: string;
|
readonly installedAt: string;
|
||||||
/** Channel and verified client range are immutable facts of this install. */
|
/** Channel is the latest explicit selection intent; verified client range is immutable. */
|
||||||
readonly channel?: 'stable' | 'beta';
|
readonly channel?: 'stable' | 'beta';
|
||||||
readonly minMakeloreVersion?: string;
|
readonly minMakeloreVersion?: string;
|
||||||
readonly maxMakeloreVersion?: string | null;
|
readonly maxMakeloreVersion?: string | null;
|
||||||
@@ -628,6 +628,7 @@ export class PluginPackageStore {
|
|||||||
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||||
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
|
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
|
||||||
private readonly activeWorkers = new Set<string>();
|
private readonly activeWorkers = new Set<string>();
|
||||||
|
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
|
||||||
private readonly unsubscribeSession: (() => void) | null;
|
private readonly unsubscribeSession: (() => void) | null;
|
||||||
private operation: Promise<void> = Promise.resolve();
|
private operation: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -691,7 +692,11 @@ export class PluginPackageStore {
|
|||||||
fail('plugin_beta_selection_required');
|
fail('plugin_beta_selection_required');
|
||||||
}
|
}
|
||||||
const binding = this.requireBinding();
|
const binding = this.requireBinding();
|
||||||
return this.withOperation(() => this.resolveAndInstallLocked(input, binding));
|
return this.withOperation(async () => {
|
||||||
|
const result = await this.resolveAndInstallLocked(input, binding);
|
||||||
|
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||||
@@ -714,7 +719,13 @@ export class PluginPackageStore {
|
|||||||
this.assertBinding(binding);
|
this.assertBinding(binding);
|
||||||
this.accountCache.invalidatePlugin(binding, validated);
|
this.accountCache.invalidatePlugin(binding, validated);
|
||||||
this.assertBinding(binding);
|
this.assertBinding(binding);
|
||||||
return this.removeUnusedLocked(validated, binding, 'explicit');
|
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
|
||||||
|
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
|
||||||
|
this.pendingExplicitCleanupPluginIds.add(validated);
|
||||||
|
} else {
|
||||||
|
this.pendingExplicitCleanupPluginIds.delete(validated);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,8 +733,15 @@ export class PluginPackageStore {
|
|||||||
this.activeWorkers.add(validReleaseId(releaseId));
|
this.activeWorkers.add(validReleaseId(releaseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseActiveWorker(releaseId: string): void {
|
async releaseActiveWorker(releaseId: string): Promise<void> {
|
||||||
this.activeWorkers.delete(validReleaseId(releaseId));
|
this.activeWorkers.delete(validReleaseId(releaseId));
|
||||||
|
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
|
||||||
|
await this.withOperation(async () => {
|
||||||
|
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
|
||||||
|
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
|
||||||
|
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
|
||||||
@@ -788,12 +806,13 @@ export class PluginPackageStore {
|
|||||||
if (item.action === 'keep') {
|
if (item.action === 'keep') {
|
||||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||||
this.assertBinding(binding);
|
this.assertBinding(binding);
|
||||||
|
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
|
||||||
return {
|
return {
|
||||||
status: 'kept',
|
status: 'kept',
|
||||||
pluginId,
|
pluginId,
|
||||||
releaseId: current.releaseId,
|
releaseId: current.releaseId,
|
||||||
version: current.version,
|
version: current.version,
|
||||||
...(current.channel === undefined ? {} : { channel: current.channel }),
|
channel,
|
||||||
packageRoot: current.packageRoot,
|
packageRoot: current.packageRoot,
|
||||||
definition: current.definition,
|
definition: current.definition,
|
||||||
};
|
};
|
||||||
@@ -814,6 +833,7 @@ export class PluginPackageStore {
|
|||||||
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
||||||
if (existing && !existing.unavailableReason) {
|
if (existing && !existing.unavailableReason) {
|
||||||
this.assertBinding(binding);
|
this.assertBinding(binding);
|
||||||
|
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
|
||||||
await this.setCurrentSelection(
|
await this.setCurrentSelection(
|
||||||
await this.readCurrentSelection(),
|
await this.readCurrentSelection(),
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -824,7 +844,7 @@ export class PluginPackageStore {
|
|||||||
pluginId,
|
pluginId,
|
||||||
releaseId: existing.releaseId,
|
releaseId: existing.releaseId,
|
||||||
version: existing.version,
|
version: existing.version,
|
||||||
...(existing.channel === undefined ? {} : { channel: existing.channel }),
|
channel,
|
||||||
packageRoot: existing.packageRoot,
|
packageRoot: existing.packageRoot,
|
||||||
definition: existing.definition,
|
definition: existing.definition,
|
||||||
};
|
};
|
||||||
@@ -1016,6 +1036,33 @@ export class PluginPackageStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async persistSelectedChannel(
|
||||||
|
index: IndexDocument,
|
||||||
|
pluginId: string,
|
||||||
|
releaseId: string,
|
||||||
|
channel: 'stable' | 'beta',
|
||||||
|
binding: AccountBinding,
|
||||||
|
): Promise<void> {
|
||||||
|
const record = index.releases.find((candidate) => (
|
||||||
|
candidate.pluginId === pluginId && candidate.releaseId === releaseId
|
||||||
|
));
|
||||||
|
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
|
||||||
|
if (record.channel === channel) return;
|
||||||
|
this.assertBinding(binding);
|
||||||
|
const releases = index.releases.map((candidate) => (
|
||||||
|
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
|
||||||
|
));
|
||||||
|
try {
|
||||||
|
await this.writeIndex(this.indexPath, serializeIndex({
|
||||||
|
schema_version: INDEX_SCHEMA_VERSION,
|
||||||
|
releases,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
|
||||||
|
}
|
||||||
|
this.assertBinding(binding);
|
||||||
|
}
|
||||||
|
|
||||||
private async removeUnusedLocked(
|
private async removeUnusedLocked(
|
||||||
validated: string,
|
validated: string,
|
||||||
binding?: AccountBinding,
|
binding?: AccountBinding,
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export interface PiManagedWorkerOpenerOptions {
|
|||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
bundledSkillsDir: string;
|
bundledSkillsDir: string;
|
||||||
getSkillRoots?(): readonly string[] | Promise<readonly string[]>;
|
getSkillRoots?(): readonly string[] | Promise<readonly string[]>;
|
||||||
registerActivePluginReleases?(releaseIds: readonly string[]): () => void;
|
registerActivePluginReleases?(releaseIds: readonly string[]): () => void | Promise<void>;
|
||||||
loadProviderInput(): Promise<PiManagedProviderInput>;
|
loadProviderInput(): Promise<PiManagedProviderInput>;
|
||||||
resolveCredential(account: ProviderAccount): Promise<string | null>;
|
resolveCredential(account: ProviderAccount): Promise<string | null>;
|
||||||
getLocalProxyCredential?(): Promise<string | undefined>;
|
getLocalProxyCredential?(): Promise<string | undefined>;
|
||||||
@@ -335,7 +335,7 @@ export function createPiManagedWorkerOpener(
|
|||||||
try {
|
try {
|
||||||
await extension.dispose();
|
await extension.dispose();
|
||||||
} finally {
|
} finally {
|
||||||
releaseActivePluginReleases?.();
|
await releaseActivePluginReleases?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => {
|
let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => {
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export function MyPluginsView(props: MyPluginsViewProps) {
|
|||||||
<section aria-label="账号插件库" className="mt-6 space-y-4">
|
<section aria-label="账号插件库" className="mt-6 space-y-4">
|
||||||
{props.library?.items.map((plugin) => {
|
{props.library?.items.map((plugin) => {
|
||||||
const installed = props.installations[plugin.pluginId];
|
const installed = props.installations[plugin.pluginId];
|
||||||
const hasDevicePackage = Boolean(installed?.version);
|
const systemIncluded = plugin.acquisition === 'system_included';
|
||||||
|
const hasDevicePackage = !systemIncluded && Boolean(installed?.version);
|
||||||
const removed = plugin.removedAt !== null;
|
const removed = plugin.removedAt !== null;
|
||||||
const suspended = plugin.runtimeStatus === 'suspended';
|
const suspended = plugin.runtimeStatus === 'suspended';
|
||||||
const retired = plugin.catalogStatus === 'retired';
|
const retired = plugin.catalogStatus === 'retired';
|
||||||
@@ -73,12 +74,13 @@ export function MyPluginsView(props: MyPluginsViewProps) {
|
|||||||
const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
|
const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
|
||||||
const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
|
const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
|
||||||
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
|
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
|
||||||
const failure = failureText(installed?.reason);
|
const failure = systemIncluded ? null : failureText(installed?.reason);
|
||||||
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
|
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>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta 版 {plugin.betaVersion}</p> : null}{hasDevicePackage ? <><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="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>{systemIncluded ? <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}{systemIncluded ? <p className="mt-1 text-muted-foreground">随 MakeLore 提供</p> : hasDevicePackage ? <><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-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">
|
<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>
|
{systemIncluded ? null
|
||||||
|
: removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}>重新免费获取</Button>
|
||||||
: !hasDevicePackage ? <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>
|
: !hasDevicePackage ? <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={`${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>
|
: 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>}
|
: <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>}
|
||||||
|
|||||||
@@ -796,6 +796,78 @@ describe('PluginPackageStore', () => {
|
|||||||
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
|
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists explicit Stable and Beta intent when resolve keeps the same Release', async () => {
|
||||||
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
|
const archive = buildSkillOnlyArchive();
|
||||||
|
const { grant, publicKey } = signedGrant(archive);
|
||||||
|
let action: 'install' | 'keep' = 'install';
|
||||||
|
const issueDownload = vi.fn(async () => grant);
|
||||||
|
const marketplace: MarketplaceClient = {
|
||||||
|
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||||
|
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||||
|
})),
|
||||||
|
issueDownload,
|
||||||
|
downloadContent: async () => archive,
|
||||||
|
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||||
|
} as MarketplaceClient;
|
||||||
|
const store = new PluginPackageStore({
|
||||||
|
rootDir: temporaryRoot,
|
||||||
|
marketplace,
|
||||||
|
getAccountBinding: () => ACCOUNT_A,
|
||||||
|
clientVersion: '1.0.0',
|
||||||
|
keyStore: new Map([['test-key', publicKey]]),
|
||||||
|
});
|
||||||
|
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||||
|
action = 'keep';
|
||||||
|
|
||||||
|
await expect(store.resolveAndInstall({
|
||||||
|
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
|
||||||
|
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
|
||||||
|
|
||||||
|
await expect(store.resolveAndInstall({
|
||||||
|
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||||
|
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
|
||||||
|
expect(issueDownload).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists explicit channel intent when resolve reuses a cached immutable Release', async () => {
|
||||||
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
|
const archive = buildSkillOnlyArchive();
|
||||||
|
const { grant, publicKey } = signedGrant(archive);
|
||||||
|
let action: 'install' | 'update' = 'install';
|
||||||
|
const issueDownload = vi.fn(async () => grant);
|
||||||
|
const marketplace: MarketplaceClient = {
|
||||||
|
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||||
|
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||||
|
})),
|
||||||
|
issueDownload,
|
||||||
|
downloadContent: async () => archive,
|
||||||
|
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||||
|
} as MarketplaceClient;
|
||||||
|
const store = new PluginPackageStore({
|
||||||
|
rootDir: temporaryRoot,
|
||||||
|
marketplace,
|
||||||
|
getAccountBinding: () => ACCOUNT_A,
|
||||||
|
clientVersion: '1.0.0',
|
||||||
|
keyStore: new Map([['test-key', publicKey]]),
|
||||||
|
});
|
||||||
|
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||||
|
action = 'update';
|
||||||
|
|
||||||
|
await expect(store.resolveAndInstall({
|
||||||
|
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
|
||||||
|
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
|
||||||
|
|
||||||
|
await expect(store.resolveAndInstall({
|
||||||
|
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||||
|
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
|
||||||
|
expect(issueDownload).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('removes only old releases and never guesses a new current selection from installedAt', async () => {
|
it('removes only old releases and never guesses a new current selection from installedAt', async () => {
|
||||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
const archive = buildSkillOnlyArchive();
|
const archive = buildSkillOnlyArchive();
|
||||||
@@ -907,7 +979,7 @@ describe('PluginPackageStore', () => {
|
|||||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes an active current Release unavailable to new workers before deferred cleanup', async () => {
|
it('finishes an explicit uninstall when the last active worker releases its frozen Release', async () => {
|
||||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
const archive = buildSkillOnlyArchive();
|
const archive = buildSkillOnlyArchive();
|
||||||
const active = signedGrant(archive, { releaseId: 'release-active' });
|
const active = signedGrant(archive, { releaseId: 'release-active' });
|
||||||
@@ -937,11 +1009,83 @@ describe('PluginPackageStore', () => {
|
|||||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
||||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||||
|
|
||||||
store.releaseActiveWorker('release-active');
|
await store.releaseActiveWorker('release-active');
|
||||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
|
|
||||||
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
|
|
||||||
});
|
|
||||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||||
|
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps deferred explicit cleanup blocked by another local account snapshot', async () => {
|
||||||
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
|
const archive = buildSkillOnlyArchive();
|
||||||
|
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
|
||||||
|
const accountCache = new AccountPluginCache();
|
||||||
|
const marketplace: MarketplaceClient = {
|
||||||
|
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||||
|
releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||||
|
})),
|
||||||
|
issueDownload: vi.fn(async () => grant),
|
||||||
|
downloadContent: async () => archive,
|
||||||
|
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||||
|
} as MarketplaceClient;
|
||||||
|
const store = new PluginPackageStore({
|
||||||
|
rootDir: temporaryRoot,
|
||||||
|
marketplace,
|
||||||
|
accountCache,
|
||||||
|
getAccountBinding: () => ACCOUNT_A,
|
||||||
|
keyStore: new Map([['test-key', publicKey]]),
|
||||||
|
clientVersion: '1.0.0',
|
||||||
|
});
|
||||||
|
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||||
|
accountCache.setResolve(ACCOUNT_B, 'account-b-reference', makeResolveResult({
|
||||||
|
makeloreVersion: '1.0.0', resolveRequestId: 'account-b-reference',
|
||||||
|
}, { releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes }));
|
||||||
|
store.registerActiveWorker('release-active');
|
||||||
|
|
||||||
|
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
|
||||||
|
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
|
||||||
|
});
|
||||||
|
await store.releaseActiveWorker('release-active');
|
||||||
|
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
||||||
|
await expect(store.readInstalledIndex()).resolves.toEqual([
|
||||||
|
expect.objectContaining({ pluginId: PLUGIN_ID, releaseId: 'release-active' }),
|
||||||
|
]);
|
||||||
|
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active')))
|
||||||
|
.resolves.toMatchObject({ isDirectory: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels deferred cleanup when the user explicitly installs the same Release again', async () => {
|
||||||
|
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||||
|
const archive = buildSkillOnlyArchive();
|
||||||
|
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
|
||||||
|
const marketplace: MarketplaceClient = {
|
||||||
|
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||||
|
releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||||
|
})),
|
||||||
|
issueDownload: vi.fn(async () => grant),
|
||||||
|
downloadContent: async () => archive,
|
||||||
|
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||||
|
} as MarketplaceClient;
|
||||||
|
const store = new PluginPackageStore({
|
||||||
|
rootDir: temporaryRoot,
|
||||||
|
marketplace,
|
||||||
|
getAccountBinding: () => ACCOUNT_A,
|
||||||
|
keyStore: new Map([['test-key', publicKey]]),
|
||||||
|
clientVersion: '1.0.0',
|
||||||
|
});
|
||||||
|
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||||
|
store.registerActiveWorker('release-active');
|
||||||
|
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
|
||||||
|
status: 'kept', reason: 'active_worker_reference',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.resolveAndInstall({
|
||||||
|
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||||
|
})).resolves.toMatchObject({ status: 'kept', releaseId: 'release-active' });
|
||||||
|
await store.releaseActiveWorker('release-active');
|
||||||
|
|
||||||
|
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-active' });
|
||||||
|
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the old release across download, signature, and extraction failures', async () => {
|
it('preserves the old release across download, signature, and extraction failures', async () => {
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
|||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
const NOW = '2026-08-22T16:00:00.000Z';
|
const NOW = '2026-08-22T16:00:00.000Z';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
class OpenerFakeProcess implements PiWorkerProcessAdapter {
|
class OpenerFakeProcess implements PiWorkerProcessAdapter {
|
||||||
readonly generation = 1;
|
readonly generation = 1;
|
||||||
readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = [];
|
readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = [];
|
||||||
@@ -151,9 +157,13 @@ describe('managed Pi worker opener', () => {
|
|||||||
effectiveSnapshot,
|
effectiveSnapshot,
|
||||||
})),
|
})),
|
||||||
} as unknown as CodingCapabilityRegistry;
|
} as unknown as CodingCapabilityRegistry;
|
||||||
|
const firstReleaseCleanup = deferred<void>();
|
||||||
const releaseCleanups: Array<ReturnType<typeof vi.fn>> = [];
|
const releaseCleanups: Array<ReturnType<typeof vi.fn>> = [];
|
||||||
const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => {
|
const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => {
|
||||||
const cleanup = vi.fn();
|
const waitForFirst = releaseCleanups.length === 0;
|
||||||
|
const cleanup = vi.fn(async () => {
|
||||||
|
if (waitForFirst) await firstReleaseCleanup.promise;
|
||||||
|
});
|
||||||
releaseCleanups.push(cleanup);
|
releaseCleanups.push(cleanup);
|
||||||
return cleanup;
|
return cleanup;
|
||||||
});
|
});
|
||||||
@@ -252,7 +262,15 @@ describe('managed Pi worker opener', () => {
|
|||||||
expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]);
|
expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]);
|
||||||
expect(registerActivePluginReleases).toHaveBeenCalledTimes(3);
|
expect(registerActivePluginReleases).toHaveBeenCalledTimes(3);
|
||||||
expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']);
|
expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']);
|
||||||
await first.worker.stop('test_injection');
|
let firstStopSettled = false;
|
||||||
|
const firstStop = first.worker.stop('test_injection').then((result) => {
|
||||||
|
firstStopSettled = true;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(releaseCleanups[0]).toHaveBeenCalledOnce());
|
||||||
|
expect(firstStopSettled).toBe(false);
|
||||||
|
firstReleaseCleanup.resolve();
|
||||||
|
await firstStop;
|
||||||
await reopened.worker.stop('test_injection');
|
await reopened.worker.stop('test_injection');
|
||||||
await restarted.worker.stop('test_injection');
|
await restarted.worker.stop('test_injection');
|
||||||
expect(releaseCleanups).toHaveLength(3);
|
expect(releaseCleanups).toHaveLength(3);
|
||||||
|
|||||||
@@ -112,6 +112,23 @@ describe('My Plugins', () => {
|
|||||||
expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
|
expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows system-included plugins as supplied with MakeLore without device package actions', () => {
|
||||||
|
const systemIncluded = library.items[3]!;
|
||||||
|
render(<MemoryRouter><MyPluginsView
|
||||||
|
library={{ ...library, items: [systemIncluded] }} installations={{}} state="ready"
|
||||||
|
pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
|
||||||
|
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
|
||||||
|
/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
|
||||||
|
expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '下载开发数据服务' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '更新开发数据服务' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '删除设备上的开发数据服务' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
it('updates an installed Beta package only through the explicit Beta action', () => {
|
it('updates an installed Beta package only through the explicit Beta action', () => {
|
||||||
const onUpdate = vi.fn();
|
const onUpdate = vi.fn();
|
||||||
const onInstallBeta = vi.fn();
|
const onInstallBeta = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user