From 2c53823dac084a76c826eff5b97b1023b722d9cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 17:46:25 +0000 Subject: [PATCH] Marketplace: support an optional beta build per plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A market plugin can now advertise a beta build alongside its stable one — same id, same label/description, a separate version + manifest URL(s) nested under an optional `beta` field. Design constraint from the user: only ONE version of a given plugin is ever installed at a time, regardless of channel — picking the other channel replaces whatever's installed, it never installs a second copy alongside it. So the identity/conflict rules (assertNoConflictingInstall, findCoupledRecord, recordKey) needed no changes at all — installing "the beta" is just installing from a different URL for the same id, exactly like an update already works. market.ts: MarketPluginVariant factors out the three fields needed to actually install something (version, pluginManifestUrl, serviceManifestUrl) — MarketPlugin embeds one directly (the stable build) and optionally a second under `beta`. toMarketPlugin validates each independently: a malformed `beta` sub-object only drops the beta option for that plugin, it doesn't reject the whole catalog entry (same principle already applied to a malformed entry in the plugins array). SettingsMarketplace.tsx: MarketPluginCard now infers which channel is currently installed by comparing the installed version against plugin.version vs plugin.beta.version (no schema change needed on the installed-record side — a plugin only ever has one record, whichever channel it came from). Not installed + a beta exists → both "Install" and "Install Beta" show up front. Installed → the primary button still does its normal update-or-installed thing for whichever channel is running, and a secondary "Switch to Beta"/"Switch to Stable" button offers the other channel. installTarget now carries {plugin, channel} instead of just plugin, so the confirm dialog resolves its URLs from the right variant. Added a channel badge next to the version (visible only when the plugin actually has a beta build, so plugins that never published one don't get a "Stable" label nobody asked for), and switched the version shown to the plugin's ACTUAL installed version rather than always the catalog's stable version. New i18n keys: installBeta, switchToBeta, switchToStable, channelStable, channelBeta (vi copy intentionally distinct from en — "Stable"/"Beta" as bare loanwords would trip i18n.test.ts's untranslated-pair check). Tests: market.test.ts covers parsing/validating the beta field in isolation; SettingsMarketplace.test.tsx covers the fresh-install two-button case, installing beta resolves the beta URL (not stable's), and both switch directions show the right button/hide the wrong one. Verified each new assertion by temporarily reverting the corresponding logic and confirming the test fails for the right reason, then restoring it. This is the plugin/market half of beta-channel support — the app's own update channel (DevTool itself) is a separate, larger piece of work for later. --- src/components/SettingsMarketplace.test.tsx | 117 ++++++++++++++++++++ src/components/SettingsMarketplace.tsx | 110 ++++++++++++++---- src/lib/i18n.ts | 7 ++ src/lib/market.test.ts | 71 ++++++++++++ src/lib/market.ts | 60 +++++++--- 5 files changed, 328 insertions(+), 37 deletions(-) diff --git a/src/components/SettingsMarketplace.test.tsx b/src/components/SettingsMarketplace.test.tsx index 3c998f3..a8d68f4 100644 --- a/src/components/SettingsMarketplace.test.tsx +++ b/src/components/SettingsMarketplace.test.tsx @@ -199,6 +199,123 @@ describe('SettingsMarketplace — cài đặt (dialog xác nhận ngay tại tra expect(installButton.disabled).toBe(false); }); + it('plugin có bản beta: chưa cài thì hiện CẢ hai nút Install và Install Beta', async () => { + manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse( + demoCatalog({ + beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' }, + }), + ), + ), + ); + + await renderMarketplace(); + await settle(); + await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy()); + + expect(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Install Beta|Cài bản Beta/ })).toBeTruthy(); + }); + + it('bấm "Install Beta" mở dialog xem trước ĐÚNG URL của bản beta, không phải bản stable', async () => { + manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse( + demoCatalog({ + beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' }, + }), + ), + ), + ); + + await renderMarketplace(); + await settle(); + await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', { name: /Install Beta|Cài bản Beta/ })); + + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByText(/demo@1\.1\.0-beta\.1/)).toBeTruthy()); + + fireEvent.click(within(dialog).getByRole('button', { name: /^Install$|^Cài đặt$/ })); + + await waitFor(() => expect(installMock).toHaveBeenCalledTimes(1)); + expect(installMock).toHaveBeenCalledWith({ + sourceUrl: 'https://example.com/demo-plugin-beta.json', + marketId: 'official', + }); + }); + + it('đã cài bản beta thì hiện nút "Switch to Stable", KHÔNG phải "Switch to Beta"', async () => { + installedArtifacts = [ + { + kind: 'plugin', + manifest: { ...demoPluginManifest(), version: '1.1.0-beta.1' }, + source_url: 'https://example.com/demo-plugin-beta.json', + bundle_path: '/tmp/demo/bundle.mjs', + installed_at: 1_700_000_000_000, + market_id: 'official', + }, + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse( + demoCatalog({ + beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' }, + }), + ), + ), + ); + + await renderMarketplace(); + await settle(); + + // Hàng hiện version THẬT đã cài (bản beta), không phải version stable của + // catalog — cùng lý do đã ghi trong MarketPluginCard. + await waitFor(() => expect(screen.getByText(/demo@1\.1\.0-beta\.1/)).toBeTruthy()); + expect(screen.getByRole('button', { name: /Switch to Stable|Chuyển về Stable/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /Switch to Beta|Chuyển sang Beta/ })).toBeNull(); + // Nút chính (cập nhật bản beta) bị vô hiệu vì đã là bản beta mới nhất. + expect((screen.getByRole('button', { name: /^Installed$|^Đã cài$/ }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('đã cài bản stable, có beta mới hơn thì hiện nút "Switch to Beta"', async () => { + manifestByUrl['https://example.com/demo-plugin-beta.json'] = { ...demoPluginManifest(), version: '1.1.0-beta.1' }; + installedArtifacts = [ + { + kind: 'plugin', + manifest: demoPluginManifest(), + source_url: 'https://example.com/demo-plugin.json', + bundle_path: '/tmp/demo/bundle.mjs', + installed_at: 1_700_000_000_000, + market_id: 'official', + }, + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse( + demoCatalog({ + beta: { version: '1.1.0-beta.1', pluginManifestUrl: 'https://example.com/demo-plugin-beta.json' }, + }), + ), + ), + ); + + await renderMarketplace(); + await settle(); + + await waitFor(() => expect(screen.getByText(/demo@1\.0\.0/)).toBeTruthy()); + expect(screen.getByRole('button', { name: /Switch to Beta|Chuyển sang Beta/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /Switch to Stable|Chuyển về Stable/ })).toBeNull(); + }); + it('đã cài đúng bản mới nhất thì nút Install bị vô hiệu hoá, hiện "Installed"', async () => { installedArtifacts = [ { diff --git a/src/components/SettingsMarketplace.tsx b/src/components/SettingsMarketplace.tsx index 7a5bdb5..d12d602 100644 --- a/src/components/SettingsMarketplace.tsx +++ b/src/components/SettingsMarketplace.tsx @@ -46,16 +46,24 @@ export function SettingsMarketplace() { const [newLabel, setNewLabel] = useState(''); const [newUrl, setNewUrl] = useState(''); - // Plugin đang xem trước/xác nhận cài — `null` khi dialog đóng. - const [installTarget, setInstallTarget] = useState(null); - // Nhớ lại theo danh tính plugin, không tính mới mỗi lần render — nếu không, - // một re-render bất kỳ trong lúc dialog đang mở (vd `refreshInstalled` sau - // khi cài xong) tạo một mảng URL MỚI mỗi lần, khiến effect fetch bên trong - // `ExtensionInstallDialog` (khoá theo tham chiếu `urls`) chạy lại vô ích. - const installUrls = useMemo( - () => (installTarget ? [installTarget.pluginManifestUrl, installTarget.serviceManifestUrl].filter((u): u is string => Boolean(u)) : []), - [installTarget], + // Plugin + kênh đang xem trước/xác nhận cài — `null` khi dialog đóng. Kênh + // đi kèm plugin (không phải state riêng) vì `installUrls` bên dưới phải + // biết lấy URL từ field phẳng của plugin (stable) hay từ `plugin.beta` — + // xem `MarketPluginCard`'s hai nút Install/Switch channel. + const [installTarget, setInstallTarget] = useState<{ plugin: MarketPlugin; channel: 'stable' | 'beta' } | null>( + null, ); + // Nhớ lại theo danh tính plugin+kênh, không tính mới mỗi lần render — nếu + // không, một re-render bất kỳ trong lúc dialog đang mở (vd + // `refreshInstalled` sau khi cài xong) tạo một mảng URL MỚI mỗi lần, khiến + // effect fetch bên trong `ExtensionInstallDialog` (khoá theo tham chiếu + // `urls`) chạy lại vô ích. + const installUrls = useMemo(() => { + if (!installTarget) return []; + const variant = installTarget.channel === 'beta' ? installTarget.plugin.beta : installTarget.plugin; + if (!variant) return []; + return [variant.pluginManifestUrl, variant.serviceManifestUrl].filter((u): u is string => Boolean(u)); + }, [installTarget]); const refreshInstalled = useCallback(async () => { if (!isTauri) return; @@ -231,7 +239,7 @@ export function SettingsMarketplace() { (r) => r.kind === 'plugin' && r.manifest.id === p.id && (r.marketId === selected?.id || r.marketId === undefined), )} targetTriple={targetTriple} - onInstall={() => setInstallTarget(p)} + onInstall={(channel) => setInstallTarget({ plugin: p, channel })} /> ))} @@ -257,12 +265,24 @@ function MarketPluginCard({ plugin: MarketPlugin; installedRecord: InstalledArtifactRecord | undefined; targetTriple: string | null; - onInstall: () => void; + onInstall: (channel: 'stable' | 'beta') => void; }) { const { t } = useLocale(); const installedVersion = installedRecord?.kind === 'plugin' ? installedRecord.manifest.version : undefined; - const upToDate = installedVersion === plugin.version; + const hasBeta = !!plugin.beta; + // Suy ra kênh ĐANG CÀI từ version đã cài khớp field nào — `installer.ts` + // không lưu channel tường minh (một plugin chỉ có MỘT bản ghi cài, dù stable + // hay beta, xem doc comment `MarketPlugin.beta`), nên đây là cách duy nhất + // biết được mà không cần đổi hình dạng InstalledPluginRecord phía Rust. + // Khớp bản beta thì coi là 'beta'; mọi trường hợp khác (khớp stable, hoặc + // version cũ không còn trong catalog) mặc định 'stable' — đúng giả định gốc + // trước khi có beta: mọi bản cài đều là stable. + const installedChannel: 'stable' | 'beta' | undefined = + installedVersion === undefined ? undefined : installedVersion === plugin.beta?.version ? 'beta' : 'stable'; + const upToDateStable = installedVersion === plugin.version; + const upToDateBeta = hasBeta && installedVersion === plugin.beta!.version; + const upToDateCurrent = installedChannel === 'beta' ? upToDateBeta : installedChannel === 'stable' && upToDateStable; const unsupported = !!plugin.targets && plugin.targets.length > 0 && !!targetTriple && !plugin.targets.includes(targetTriple); @@ -270,14 +290,26 @@ function MarketPluginCard({

{plugin.label}

-

{plugin.id}@{plugin.version}

+

+ {plugin.id}@{installedVersion ?? plugin.version} + {/* Chỉ hiện nhãn kênh khi plugin THẬT SỰ có bản beta — một plugin + chưa từng phát hành beta thì "Stable" trên mọi thẻ chỉ là chữ + thừa, không giúp phân biệt gì cả. */} + {hasBeta && installedChannel && ( + + {installedChannel === 'beta' + ? t('settings.extensions.marketplace.channelBeta') + : t('settings.extensions.marketplace.channelStable')} + + )} +

@@ -301,13 +333,49 @@ function MarketPluginCard({ không chính thức có thể thiếu triple dù bản build thật sự có), cùng nguyên tắc SettingsExtensionInstaller đã áp cho cùng field này: xem trước rồi để người dùng tự quyết, không tự ý từ chối thay họ. */} - +
+ {installedChannel === undefined ? ( + // Chưa cài: hai lựa chọn ngay từ đầu nếu có bản beta — không ép + // cài stable rồi mới "chuyển kênh" sau, người dùng biết ngay có + // beta để chọn thẳng nếu muốn. + <> + + {hasBeta && ( + + )} + + ) : installedChannel === 'beta' ? ( + <> + + {/* Bản stable (field phẳng của plugin) luôn tồn tại — chuyển về + stable lúc nào cũng là một lựa chọn hợp lệ khi đang ở beta. */} + + + ) : ( + <> + + {hasBeta && ( + + )} + + )} +
); } diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 503bd00..d43f9c0 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -336,8 +336,15 @@ export const DICTIONARY = { en: 'This market has no extensions yet.', }, 'settings.extensions.marketplace.install': { vi: 'Cài đặt', en: 'Install' }, + 'settings.extensions.marketplace.installBeta': { vi: 'Cài bản Beta', en: 'Install Beta' }, 'settings.extensions.marketplace.installed': { vi: 'Đã cài', en: 'Installed' }, 'settings.extensions.marketplace.updateTo': { vi: 'Cập nhật lên {{version}}', en: 'Update to {{version}}' }, + 'settings.extensions.marketplace.switchToBeta': { vi: 'Chuyển sang Beta', en: 'Switch to Beta' }, + 'settings.extensions.marketplace.switchToStable': { vi: 'Chuyển về Stable', en: 'Switch to Stable' }, + // Nhãn kênh cạnh version đã cài — CHỈ hiện khi có bản beta (plugin.beta), + // để không thêm chữ thừa cho plugin chưa từng phát hành beta bao giờ. + 'settings.extensions.marketplace.channelStable': { vi: 'Bản ổn định', en: 'Stable' }, + 'settings.extensions.marketplace.channelBeta': { vi: 'Bản Beta', en: 'Beta' }, 'settings.extensions.marketplace.unsupportedPlatform': { vi: 'Không có bản cho nền tảng máy này', en: 'No build for this machine\'s platform', diff --git a/src/lib/market.test.ts b/src/lib/market.test.ts index ee71a4e..9754844 100644 --- a/src/lib/market.test.ts +++ b/src/lib/market.test.ts @@ -134,6 +134,77 @@ describe('fetchMarketCatalog', () => { expect(plugins[0].id).toBe('ok'); }); + it('parse đúng field "beta" khi tác giả có phát hành bản beta', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + plugins: [ + { + id: 'demo', + label: 'Demo', + description: 'A demo plugin', + version: '1.0.0', + pluginManifestUrl: 'https://example.com/demo-plugin.json', + beta: { + version: '1.1.0-beta.1', + pluginManifestUrl: 'https://example.com/demo-plugin-beta.json', + serviceManifestUrl: 'https://example.com/demo-service-beta.json', + }, + }, + ], + }), + ), + ); + + const plugins = await fetchMarketCatalog('https://example.com/catalog.json'); + expect(plugins[0].beta).toEqual({ + version: '1.1.0-beta.1', + pluginManifestUrl: 'https://example.com/demo-plugin-beta.json', + serviceManifestUrl: 'https://example.com/demo-service-beta.json', + }); + }); + + it('không có "beta" thì field đó vắng mặt, không phải lỗi hình dạng', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + plugins: [ + { id: 'demo', label: 'Demo', description: 'd', version: '1.0.0', pluginManifestUrl: 'https://example.com/demo.json' }, + ], + }), + ), + ); + + const plugins = await fetchMarketCatalog('https://example.com/catalog.json'); + expect(plugins[0].beta).toBeUndefined(); + }); + + it('"beta" sai hình dạng (thiếu version) chỉ làm rớt bản beta, không loại cả plugin', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + plugins: [ + { + id: 'demo', + label: 'Demo', + description: 'd', + version: '1.0.0', + pluginManifestUrl: 'https://example.com/demo.json', + beta: { pluginManifestUrl: 'https://example.com/demo-beta.json' }, // thiếu version + }, + ], + }), + ), + ); + + const plugins = await fetchMarketCatalog('https://example.com/catalog.json'); + expect(plugins).toHaveLength(1); + expect(plugins[0].beta).toBeUndefined(); + }); + it('ném lỗi rõ ràng khi HTTP lỗi', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(null, false, 404))); await expect(fetchMarketCatalog('https://example.com/catalog.json')).rejects.toThrow('HTTP 404'); diff --git a/src/lib/market.ts b/src/lib/market.ts index 52c83c1..9284802 100644 --- a/src/lib/market.ts +++ b/src/lib/market.ts @@ -23,12 +23,11 @@ export interface Market { builtin: boolean; } -export interface MarketPlugin { - id: string; - label: string; - description: string; +/** Một bản cài được — dùng cho cả bản stable (field phẳng của `MarketPlugin`) + * lẫn bản `beta` tuỳ chọn: cùng ba thứ cần để cài (version, URL manifest + * plugin, URL manifest service nếu có), khác nhau đúng một tầng lồng. */ +export interface MarketPluginVariant { version: string; - keywords: string[]; pluginManifestUrl: string; serviceManifestUrl?: string; /** Target triple mà sidecar (nếu có) hỗ trợ — thiếu/rỗng nghĩa là không rõ, @@ -36,6 +35,23 @@ export interface MarketPlugin { targets?: string[]; } +export interface MarketPlugin extends MarketPluginVariant { + id: string; + label: string; + description: string; + keywords: string[]; + /** + * Bản beta của CÙNG plugin này, nếu tác giả có phát hành — cùng id, khác + * version/URL. Một plugin CHỈ được cài MỘT bản tại một thời điểm (channel + * không phải một chiều cài độc lập như market khác nhau): chọn cài bản + * beta khi đang có bản stable (hoặc ngược lại) THAY THẾ hẳn bản đang cài, + * không cài chồng hai bản — xem `SettingsMarketplace.tsx`'s nút "Switch + * channel". Thiếu field này nghĩa là tác giả chưa phát hành beta, không + * phải lỗi hình dạng. + */ + beta?: MarketPluginVariant; +} + const BUILTIN_MARKETS: Market[] = [ { id: 'official', @@ -139,27 +155,39 @@ async function marketFetch(url: string): Promise { return fetch(url); } +/** Rẽ nhánh dùng chung cho cả field phẳng của `MarketPlugin` lẫn `r.beta` — + * cùng ba field bắt buộc/tuỳ chọn của `MarketPluginVariant`. `r`/`raw` tách + * riêng vì `beta` không tự có `id`/`label`/`description`/`keywords` (kế thừa + * từ plugin cha), chỉ có phần "cài được" mà thôi. */ +function toMarketPluginVariant(raw: unknown): MarketPluginVariant | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (!isNonEmptyString(r.version) || !isNonEmptyString(r.pluginManifestUrl)) return null; + return { + version: r.version, + pluginManifestUrl: r.pluginManifestUrl, + serviceManifestUrl: isNonEmptyString(r.serviceManifestUrl) ? r.serviceManifestUrl : undefined, + targets: Array.isArray(r.targets) ? r.targets.filter(isNonEmptyString) : undefined, + }; +} + function toMarketPlugin(raw: unknown): MarketPlugin | null { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; - if ( - !isNonEmptyString(r.id) || - !isNonEmptyString(r.label) || - !isNonEmptyString(r.description) || - !isNonEmptyString(r.version) || - !isNonEmptyString(r.pluginManifestUrl) - ) { + const stable = toMarketPluginVariant(r); + if (!stable || !isNonEmptyString(r.id) || !isNonEmptyString(r.label) || !isNonEmptyString(r.description)) { return null; } return { + ...stable, id: r.id, label: r.label, description: r.description, - version: r.version, keywords: Array.isArray(r.keywords) ? r.keywords.filter(isNonEmptyString) : [], - pluginManifestUrl: r.pluginManifestUrl, - serviceManifestUrl: isNonEmptyString(r.serviceManifestUrl) ? r.serviceManifestUrl : undefined, - targets: Array.isArray(r.targets) ? r.targets.filter(isNonEmptyString) : undefined, + // Hình dạng sai (thiếu version/pluginManifestUrl bên trong `beta`) chỉ + // làm RỚT bản beta đó, không loại cả plugin — cùng nguyên tắc + // `fetchMarketCatalog`'s doc comment đã ghi cho một MỤC catalog hỏng. + beta: toMarketPluginVariant(r.beta) ?? undefined, }; }