diff --git a/docs/plugin-sdk/02-manifest.md b/docs/plugin-sdk/02-manifest.md index 4b5ec8a8..44c7c0ee 100644 --- a/docs/plugin-sdk/02-manifest.md +++ b/docs/plugin-sdk/02-manifest.md @@ -44,15 +44,22 @@ export default definePlugin({ [^market-id]: Trong CHÍNH MỘT market (một `catalog.json`), `id` vẫn phải duy nhất — không có cơ chế nào ở đây gỡ khỏi tác giả trách nhiệm không đụng id - plugin khác trong cùng market mình phát hành. Nhưng khi cài từ Settings → - Marketplace, nếu người dùng đã thêm HAI market khác nhau cùng phát hành - một `id` trùng nhau (hai tác giả không hề biết nhau), host tự phân biệt - hai bản cài đó bằng `marketId + id` (registry id thật trở thành - `-`, route thành `/installed/-`) — cả hai - cùng tồn tại, không bản nào âm thầm bị ghi đè. Một plugin cài qua URL dán - tay (không qua market nào) vẫn dùng đúng `id`/`route` gốc như trước, không - đổi gì. Xem `src/platform/installer.ts`'s `installedPluginManifests()` và - `src-tauri/src/artifact_installer.rs`'s `InstalledPluginRecord::market_id`. + plugin khác trong cùng market mình phát hành. `id` khai ở đây LUÔN giữ + nguyên dạng trần khi đăng ký vào host — không bị host tiền tố gì thêm, + dù cài qua URL dán tay hay qua market: đây chính là chuỗi plugin của bạn + tự dùng để gọi lại chính mình qua `usePluginSdkFor(id)`/`getPluginSdk(id)`. + Nguồn cài (market nào, hay "url" cho URL dán tay) được host lưu riêng + dưới dạng `group` — chỉ ảnh hưởng route hiển thị + (`/installed//`), không đụng tới `id` bạn khai. + Vì `usePluginSdkFor`/`getPluginSdk` chỉ nhận được `id` (không có + `group`) khi plugin tự gọi mình ở phạm vi module (store nền, ngoài React + context), host CHẶN VIỆC CÀI nếu `id` bạn khai đã bị một nguồn khác + (market khác, URL dán tay, hay một tool có sẵn của chính app) chiếm — + người dùng phải gỡ bản cũ trước khi cài bản mới trùng id, hai bản không + được phép cùng tồn tại. Đặt `id` đủ cụ thể (không trùng tên phổ biến + kiểu `client`/`manager`) để tránh việc này. Xem + `src/platform/installer.ts`'s `installedPluginManifests()`/ + `assertNoConflictingInstall()`. ## Bảng quyền (`PluginPermission`) diff --git a/src/components/ExtensionInstallDialog.tsx b/src/components/ExtensionInstallDialog.tsx index 4f8b613a..5d5c0980 100644 --- a/src/components/ExtensionInstallDialog.tsx +++ b/src/components/ExtensionInstallDialog.tsx @@ -7,8 +7,10 @@ import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; import { Spinner } from '@/components/ui/spinner'; import { + assertNoConflictingInstall, currentTargetTriple, fetchArtifactManifestPreview, + getPlugin, installArtifact, type RemoteArtifactManifest, } from '@/platform'; @@ -94,6 +96,9 @@ export function ExtensionInstallDialog({ urls, marketId, open, onOpenChange, onI setInstalling(true); try { for (const item of installable) { + if (item.manifest.kind === 'plugin') { + await assertNoConflictingInstall(item.manifest.id, marketId, getPlugin(item.manifest.id)?.group); + } await installArtifact(item.url, marketId); } setDone(true); diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index 2f4d8646..f0516aed 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -210,6 +210,20 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { expect( screen.getByText(/The manifest has a build for this platform\.|Manifest có bản cho nền tảng này\./), ).toBeTruthy(); + + // kind=service KHÔNG đi qua assertNoConflictingInstall (chỉ áp dụng cho + // plugin) — bấm Install ở đây phải đi thẳng tới artifact_installer_install, + // không có lệnh list nào chen vào trước. + invokeMock.mockResolvedValueOnce(installedServiceRaw()); + invokeMock.mockResolvedValueOnce([installedServiceRaw()]); // refresh sau khi cài + fireEvent.click(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })); + + await waitFor(() => { + expect(screen.getByText(/External extensions changed|Đã thay đổi tiện ích/)).toBeTruthy(); + }); + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_install', { + sourceUrl: 'https://example.com/svc.json', + }); }); it('kind=service: nền tảng không khớp thì cảnh báo rõ, không đoán/thử triple khác', async () => { @@ -246,6 +260,7 @@ describe('SettingsExtensionInstaller — cài đặt (success) và danh sách h fireEvent.click(screen.getByRole('button', { name: /Preview|Xem trước/ })); await waitFor(() => expect(screen.getByText('Demo')).toBeTruthy()); + invokeMock.mockResolvedValueOnce([]); // list, assertNoConflictingInstall trước khi cài invokeMock.mockResolvedValueOnce(installedPluginRaw()); // install invokeMock.mockResolvedValueOnce([installedPluginRaw(), installedServiceRaw()]); // refresh sau khi cài diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index 27c46218..dad9fc33 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -8,10 +8,12 @@ import { Input } from '@/components/ui/input'; import { Callout } from '@/components/ui/callout'; import { Spinner } from '@/components/ui/spinner'; import { + assertNoConflictingInstall, checkForServiceUpdate, checkForUpdate, currentTargetTriple, fetchArtifactManifestPreview, + getPlugin, installArtifact, installPlugin, installService, @@ -146,6 +148,9 @@ export function SettingsExtensionInstaller() { setInstallError(null); setInstalling(true); try { + if (preview?.kind === 'plugin') { + await assertNoConflictingInstall(preview.id, previewMarketId, getPlugin(preview.id)?.group); + } await installArtifact(url.trim(), previewMarketId); setUrl(''); setPreview(null); diff --git a/src/components/SettingsExtensions.pendingInstallTab.test.tsx b/src/components/SettingsExtensions.pendingInstallTab.test.tsx index 598ca015..053ea0ef 100644 --- a/src/components/SettingsExtensions.pendingInstallTab.test.tsx +++ b/src/components/SettingsExtensions.pendingInstallTab.test.tsx @@ -37,6 +37,11 @@ vi.mock('@/platform', async (importOriginal) => { currentTargetTriple: () => Promise.resolve('x86_64-unknown-linux-gnu'), fetchArtifactManifestPreview: () => Promise.resolve(pluginManifest()), installArtifact: vi.fn().mockResolvedValue(pluginManifest()), + // Gọi TRƯỚC installArtifact ở ExtensionInstallDialog — thật (không mock) + // sẽ đi qua listInstalledPlugins() nội bộ của installer.ts (KHÔNG đi qua + // barrel này, nên mock listInstalledArtifacts ở trên không chặn được), + // rồi invoke() thật một lệnh Tauri không tồn tại trong jsdom. + assertNoConflictingInstall: () => Promise.resolve(), }; }); // `market.ts` đi qua tauri-plugin-http (không phải `invoke`) khi `isTauri` — diff --git a/src/lib/market.ts b/src/lib/market.ts index fa0e6c9c..52c83c13 100644 --- a/src/lib/market.ts +++ b/src/lib/market.ts @@ -93,11 +93,12 @@ function randomSuffix(): string { } // Kebab-case (chữ thường, số, dấu gạch nối) — CHỦ ĐÍCH, không chỉ để đẹp: -// `installedPluginManifests()` (installer.ts) ghép id này với id plugin -// thành một registry id duy nhất (`${marketId}-${pluginId}`), phải khớp -// `ID_PATTERN` của `validateManifest` (chỉ nhận kebab-case) — một market id -// chứa `:` hay ký tự hoa sẽ khiến MỌI plugin cài từ market đó bị registry từ -// chối thẳng, âm thầm biến mất khỏi sidebar. +// `installedPluginManifests()` (installer.ts) dùng market id này làm `group` +// của mọi plugin cài từ market đó, lộ ra trực tiếp trong route +// (`/installed//`) và trong `by-market//` phía đĩa +// (`artifact_installer.rs`) — một market id chứa `:`, khoảng trắng hay ký tự +// hoa sẽ làm hỏng route/đường dẫn file của MỌI plugin cài từ market đó. + export function addCustomMarket(label: string, catalogUrl: string): Market { const market: Market = { id: `custom-${Date.now()}-${randomSuffix()}`, diff --git a/src/platform/index.ts b/src/platform/index.ts index e97b0b27..2a2c80f9 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -34,6 +34,7 @@ export { currentTargetTriple, checkAllForUpdates, recordKey, + assertNoConflictingInstall, } from './installer'; export type { RemotePluginManifest, diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index fa50e5ea..bea7b29c 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -230,6 +230,92 @@ describe('fetchArtifactManifestPreview / installArtifact / listInstalledArtifact }); }); +describe('assertNoConflictingInstall — gọi TRƯỚC installArtifact/installPlugin bằng manifest lớp gọi đã xem trước', () => { + it('id đã cài từ group KHÁC (market khác, hoặc url ↔ market) — ném lỗi rõ ràng nêu tên group cũ', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'custom-fork')).rejects.toThrow( + /container-manager.*official/, + ); + }); + + it('id đã cài, CÙNG group (cài lại/cập nhật từ đúng nguồn cũ) — KHÔNG ném', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); + + it('không marketId cả hai bên (group "url" cả hai) — cài lại từ URL trần vẫn không bị chặn', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }) })]); + + await expect(installer.assertNoConflictingInstall('container-manager', undefined)).resolves.toBeUndefined(); + }); + + it('id chưa từng cài ở đâu — không chặn gì', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); + + it('đọc index lỗi (đĩa hỏng/quyền) — không chặn cài, để lệnh install thật sự tự báo lỗi rõ hơn', async () => { + const installer = await loadInTauri(); + invokeMock.mockRejectedValueOnce(new Error('index.json hỏng')); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); + + it('registeredGroup="core" (id trùng một plugin compile-time, lớp gọi tự tra bằng getPlugin) — ném lỗi riêng, không nhắc "gỡ bản đó" (không gỡ được tool có sẵn)', async () => { + const installer = await loadInTauri(); + // KHÔNG mock invoke ở đây: registeredGroup !== newGroup phải chặn NGAY, + // trước khi kịp gọi listInstalledPlugins() — nếu lỡ gọi, invokeMock + // (chưa mock resolved value nào) sẽ trả undefined, không phải mảng, và + // test sẽ thất bại vì lý do sai (TypeError .find trên undefined), không + // phải vì đúng lỗi mong đợi. + await expect(installer.assertNoConflictingInstall('json', 'official', 'core')).rejects.toThrow(/json/); + await expect(installer.assertNoConflictingInstall('json', 'official', 'core')).rejects.not.toThrow(/gỡ bản đó/i); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('registeredGroup trùng đúng group đang cài (vd cài lại chính plugin compile-time đó — không hợp lệ thật, nhưng hàm không tự biết) — không chặn ở nhánh registeredGroup', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([]); // vẫn rơi xuống nhánh quét đĩa như bình thường + + await expect(installer.assertNoConflictingInstall('json', undefined, 'url')).resolves.toBeUndefined(); + }); + + it('registeredGroup khác "core" (bản ghi registry, có thể đã STALE nếu người dùng vừa gỡ trong phiên này) — KHÔNG tự chặn, luôn rơi xuống quét đĩa', async () => { + const installer = await loadInTauri(); + // registry chỉ nạp một lần lúc bootstrap (registry.ts), không cập nhật + // khi cài/gỡ trong lúc app đang chạy — một registeredGroup non-core ở + // đây có thể là bản ghi CŨ của một plugin người dùng vừa gỡ, chưa + // restart. Tự chặn bằng registeredGroup ở trường hợp này sẽ chặn nhầm + // một lượt cài lại hợp lệ — phải luôn quét đĩa (nguồn xác thực đúng + // trạng thái hiện tại) để biết còn conflict thật hay không. + invokeMock.mockResolvedValueOnce([]); // đĩa: không còn bản nào — plugin cũ đã bị gỡ thật + await expect( + installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official'), + ).resolves.toBeUndefined(); + }); + + it('registeredGroup khác "core" NHƯNG đĩa xác nhận vẫn còn conflict thật — vẫn ném đúng thông điệp', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + await expect( + installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official'), + ).rejects.toThrow(/container-manager.*official/); + }); +}); + describe('currentTargetTriple', () => { it('gọi đúng lệnh Rust và trả nguyên chuỗi triple', async () => { const installer = await loadInTauri(); @@ -441,29 +527,30 @@ describe('installedPluginManifests — đổi RemotePluginManifest thành Plugin // trên app thật (cài một plugin ví dụ, xác nhận nó thật sự render) trước // khi coi cơ chế này là đã kiểm chứng đầy đủ. - it('không có marketId (dán tay/bản cài từ trước) thì giữ NGUYÊN id/route gốc', async () => { + it('không có marketId (dán tay/bản cài từ trước) thì group là "url", id giữ nguyên trần', async () => { const installer = await loadInTauri(); invokeMock.mockResolvedValueOnce([installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }) })]); const [a] = await installer.installedPluginManifests(); expect(a.manifest.id).toBe('demo'); - expect(a.manifest.route).toBe('/demo'); + expect(a.manifest.group).toBe('url'); + expect(a.manifest.route).toBe('/installed/url/demo'); }); - it('có marketId thì id/route đăng ký registry được ghép marketId — hai market khác nhau cùng id KHÔNG đụng route/storage của nhau', async () => { + it('có marketId thì group là marketId, id VẪN TRẦN — đây là chuỗi usePluginSdkFor()/getPluginSdk() bên trong bundle tự gọi lại chính mình', async () => { const installer = await loadInTauri(); invokeMock.mockResolvedValueOnce([ installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }), market_id: 'official' }), - installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }), market_id: 'custom-fork' }), ]); - const [a, b] = await installer.installedPluginManifests(); - expect(a.manifest.id).toBe('official-demo'); - expect(a.manifest.route).toBe('/installed/official-demo'); - expect(b.manifest.id).toBe('custom-fork-demo'); - expect(b.manifest.route).toBe('/installed/custom-fork-demo'); - expect(a.manifest.id).not.toBe(b.manifest.id); - expect(a.manifest.route).not.toBe(b.manifest.route); + const [a] = await installer.installedPluginManifests(); + // Mất dòng group ở installer.ts thì id vẫn đúng (không tiền tố), nên + // usePluginSdkFor/getPluginSdk vẫn hoạt động — group chỉ ảnh hưởng route + // hiển thị và việc phát hiện xung đột lúc CÀI (assertNoConflictingInstall), + // không phải danh tính SDK của plugin. + expect(a.manifest.id).toBe('demo'); + expect(a.manifest.group).toBe('official'); + expect(a.manifest.route).toBe('/installed/official/demo'); }); }); diff --git a/src/platform/installer.ts b/src/platform/installer.ts index c381d3ba..3c7569dc 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -209,6 +209,87 @@ function toCamelArtifactRecord(record: RawInstalledArtifactRecord): InstalledArt }; } +/** `PluginManifest.group` hiệu lực của một `marketId` — `"url"` khi không có + * market (URL dán tay), chính `marketId` khi có. Một hàm DÙNG CHUNG giữa + * `installedPluginManifests()` (đọc group cho registry) và + * `assertNoConflictingInstall()` (so group cũ/mới lúc cài) để hai chỗ không + * bao giờ lệch công thức nhau. */ +function installGroup(marketId: string | null | undefined): string { + return marketId ?? 'url'; +} + +/** + * Chặn cài một plugin nếu `id` của nó đã bị MỘT GROUP KHÁC chiếm — vd đã cài + * "container-manager" qua URL dán tay (group "url"), giờ cài lại đúng plugin + * đó nhưng từ market "official" (group "official"). Cùng `id` khác `group` + * cùng đăng ký được vào registry (`registry.ts` không cấm — mô hình là "id + * chỉ cần duy nhất TRONG group"), nhưng `usePluginSdkFor`/`getPluginSdk` gọi + * MODULE-SCOPE (không qua React context, dùng bởi store nền của Kafka/ + * RabbitMQ) chỉ nhận được đúng chuỗi `id`, không có `group` — hai bản ghi + * cùng tồn tại sẽ khiến lời gọi đó không biết nên trả bản nào, âm thầm gán + * nhầm storage/audit/quyền của bản NÀY cho plugin BẢN KIA. + * + * Chặn ở ĐÂY (trước khi Rust ghi xuống đĩa) thay vì chỉ phát hiện ở + * `registry.ts` lúc khởi động app lần sau: người dùng thấy lỗi NGAY khi bấm + * cài, kèm hướng dẫn gỡ bản cũ trước — không phải một dòng ẩn trong + * `PLUGIN_ERRORS` họ phải tự tìm ra sau khi thắc mắc sao plugin mới cài + * không lên. + * + * GỌI TRƯỚC `installArtifact`/`installPlugin`, không phải bên trong chúng — + * mọi lối gọi (ExtensionInstallDialog, SettingsExtensionInstaller) đều ĐÃ xem + * trước manifest (`fetchArtifactManifestPreview`/`fetchManifestPreview`) để + * hiển thị màn xác nhận trước khi cho bấm Install, nên `id` đã có sẵn trong + * tay — fetch lại lần hai bên trong `installArtifact` chỉ để lấy đúng cái nó + * vừa có là lãng phí không cần thiết. Chỉ áp dụng cho `kind: "plugin"` — + * service khoá theo `bin` qua `ALLOWED_SERVICES`, không thuộc mô hình + * group/id này. + * + * `registeredGroup` — group của một bản ghi ĐANG TRONG REGISTRY (không phải + * trên đĩa) đã chiếm đúng `id` này, nếu có. Lớp gọi tự tra bằng + * `getPlugin(id)?.group` (đã có sẵn qua `@/platform`) rồi truyền vào — hàm + * này KHÔNG tự tra được, vì `registry.ts` đã import `installer.ts` (nạp + * ngược lại sẽ vòng lặp). + * + * CHỈ chặn sớm ở đây khi `registeredGroup === 'core'` (một trong 26 plugin + * compile-time — không bao giờ "biến mất" khỏi registry trong lúc app đang + * chạy, nên registry luôn đúng cho trường hợp này). Mọi `registeredGroup` + * khác 'core' bị BỎ QUA ở đây, rơi xuống quét đĩa bên dưới thay vì tự chặn + * — registry chỉ nạp MỘT LẦN lúc bootstrap (`registry.ts`), không cập nhật + * khi cài/gỡ trong phiên đang chạy, nên một bản ghi non-core ở đây có thể + * đã STALE (người dùng vừa gỡ nó, chưa restart) và tự chặn nhầm một lượt + * cài lại hợp lệ. Đĩa (`listInstalledPlugins()`) luôn phản ánh đúng trạng + * thái hiện tại nên là nguồn xác thực duy nhất đáng tin cho mọi trường hợp + * không phải compile-time. + */ +export async function assertNoConflictingInstall( + id: string, + marketId: string | undefined, + registeredGroup?: string, +): Promise { + const newGroup = installGroup(marketId); + + if (registeredGroup === 'core' && registeredGroup !== newGroup) { + throw new Error(`Plugin "${id}" trùng id với một tool có sẵn trong app — đổi id khác trong manifest.`); + } + + let installed: InstalledPluginRecord[]; + try { + installed = await listInstalledPlugins(); + } catch { + // Đọc index lỗi — không phải việc của bước kiểm tra này; để lời gọi cài + // thật sự bên dưới tự báo lỗi đọc/ghi đĩa nếu có, rõ ràng hơn. + return; + } + const conflict = installed.find((r) => r.manifest.id === id && installGroup(r.marketId) !== newGroup); + if (conflict) { + const oldGroup = installGroup(conflict.marketId); + throw new Error( + `Plugin "${id}" đã được cài từ nguồn khác ("${oldGroup}"). ` + + `Gỡ bản đó trong Settings → Extensions trước khi cài từ nguồn "${newGroup}".`, + ); + } +} + // --------------------------------------------------------------------------- // Tầng tổng quát — dùng bởi SettingsExtensionInstaller (cả hai kind) // --------------------------------------------------------------------------- @@ -224,7 +305,14 @@ export async function fetchArtifactManifestPreview(url: string): Promise { const raw = await invokeCommand('artifact_installer_install', { sourceUrl: url, @@ -464,24 +552,36 @@ export async function installedPluginManifests(): Promise< } return records.map((record, i) => { - // Danh tính registry (id storage/route/quyền/audit đều khoá theo đây, xem - // registry.ts's `registerManifest`) PHẢI phân biệt được hai market khác - // nhau cùng phát hành một plugin trùng `manifest.id` — không có allowlist - // toàn cục nào ép id duy nhất giữa các market (khác `ALLOWED_SERVICES` - // của service_host.rs, một danh sách cố định phía Rust). Không có - // `marketId` (URL dán tay, hoặc bản cài từ trước khi market tồn tại) thì - // giữ NGUYÊN id/route gốc — không đổi hành vi của người dùng đã cài từ - // trước tính năng này. - const registryId = record.marketId ? `${record.marketId}-${record.manifest.id}` : record.manifest.id; + const group = installGroup(record.marketId); return { - source: `installed:${registryId}@${record.manifest.version}`, + source: `installed:${group}:${record.manifest.id}@${record.manifest.version}`, manifest: { - id: registryId, + // Bundle của plugin gọi `usePluginSdkFor(record.manifest.id)`/ + // `getPluginSdk(record.manifest.id)` bằng đúng chuỗi này — KHÔNG tiền + // tố market, xem `PluginManifest.group`. `installArtifact()` chặn cài + // nếu id này đã bị một group khác chiếm, nên tại registry nó vẫn + // nhất quán duy nhất toàn app dù không ép kiểu ở đây. + // + // Namespace storage/secrets (sdk.ts's storageKey/secrets.ts's + // vaultKey) khoá theo ĐÚNG field này — với một bản cài qua market, + // trước bản sửa này nó là registry id đã tiền tố (`-`), + // giờ là `id` trần. KHÔNG coi là cần migrate: mọi bản cài qua market + // trước bản sửa này chưa từng đọc/ghi được sdk.storage/sdk.secrets + // dù chỉ một lần — chính `usePluginSdkFor(record.manifest.id)` ở trên + // đã ném lỗi ngay khi gọi (root cause của toàn bộ chuỗi PR này), nên + // component không bao giờ chạy tới bất kỳ lời gọi storage/secrets + // nào trước khi crash. Không có dữ liệu thật nào tồn tại dưới + // namespace cũ để mất. + id: record.manifest.id, + group, label: record.manifest.label, icon: ICONS_BY_NAME[record.manifest.icon] ?? Puzzle, description: record.manifest.description, keywords: record.manifest.keywords, - route: record.marketId ? `/installed/${registryId}` : record.manifest.route, + // Thống nhất cho MỌI plugin cài lúc chạy (URL trần lẫn market) — route + // riêng plugin tự khai (`record.manifest.route`) không còn dùng nữa, + // route hiển thị luôn phản ánh đúng group đã cài. + route: `/installed/${group}/${record.manifest.id}`, order: INSTALLED_ORDER_BASE + i, defaultEnabled: true, permissions: record.manifest.permissions, diff --git a/src/platform/registry.installer.test.ts b/src/platform/registry.installer.test.ts index a2dc8e14..e09a7eac 100644 --- a/src/platform/registry.installer.test.ts +++ b/src/platform/registry.installer.test.ts @@ -119,6 +119,54 @@ describe('initInstalledPlugins', () => { expect(registry.getPlugin('installed-demo')?.route).toBe('/a'); }); + it('plugin cài qua market: id vẫn TRẦN (không tiền tố), group mang tên market — usePluginSdkFor/getPluginSdk gọi bằng id trần vẫn tra được', async () => { + // Mô phỏng đúng những gì installedPluginManifests() tự sinh cho một bản + // cài có marketId (xem installer.ts): id KHÔNG bị tiền tố, group mới là + // chỗ mang thông tin market — khác hẳn cơ chế `-` cũ. + const { installedPluginManifests } = await import('./installer'); + vi.mocked(installedPluginManifests).mockResolvedValue([ + { + manifest: installedManifest({ id: 'container-manager', group: 'official', route: '/installed/official/container-manager' }), + source: 'installed:official:container-manager@1.0.0', + }, + ]); + + const registry = await freshRegistry(); + await registry.initInstalledPlugins(); + + // Đúng chuỗi bundle của chính plugin tự gọi lại mình + // (usePluginSdkFor('container-manager')/getPluginSdk('container-manager')) + // — không cần biết registry đã gán group nào cho nó. + const record = registry.getPlugin('container-manager'); + expect(record?.id).toBe('container-manager'); + expect(record?.group).toBe('official'); + }); + + it('hai plugin cài trùng id (một qua URL trần, một qua market) — bản thứ hai vẫn bị registry từ chối như mọi trùng id khác', async () => { + // registry.ts KHÔNG tự phân biệt group khi so trùng id — đó là việc của + // installer.ts's assertNoConflictingInstall() (chặn NGAY LÚC CÀI, xem + // installer.test.ts). Ở tầng registry, hai bản ghi cùng id trần luôn là + // một collision phẳng, bất kể group — giống hệt "hai plugin cài trùng id + // với nhau thì chỉ cái đầu được đăng ký" phía trên, chỉ khác nguồn cài. + const { installedPluginManifests } = await import('./installer'); + vi.mocked(installedPluginManifests).mockResolvedValue([ + { + manifest: installedManifest({ id: 'container-manager', group: 'url', route: '/installed/url/container-manager' }), + source: 'installed:url:container-manager@1.0.0', + }, + { + manifest: installedManifest({ id: 'container-manager', group: 'official', route: '/installed/official/container-manager', order: 100_001 }), + source: 'installed:official:container-manager@1.0.0', + }, + ]); + + const registry = await freshRegistry(); + await registry.initInstalledPlugins(); + + expect(registry.getPlugin('container-manager')?.group).toBe('url'); + expect(registry.PLUGIN_ERRORS.some((e) => e.id === 'container-manager')).toBe(true); + }); + it('manifest hỏng (thiếu route) bị loại vào PLUGIN_ERRORS, không làm hỏng những manifest hợp lệ khác', async () => { const { installedPluginManifests } = await import('./installer'); vi.mocked(installedPluginManifests).mockResolvedValue([ diff --git a/src/platform/registry.ts b/src/platform/registry.ts index 305e7cfa..82e9370b 100644 --- a/src/platform/registry.ts +++ b/src/platform/registry.ts @@ -33,6 +33,14 @@ const records: PluginRecord[] = []; * đây là bắt buộc, không phải trang trí. */ export const PLUGIN_MAP: Map = new Map(); +// `id` phải duy nhất TOÀN APP trên thực tế, không chỉ trong `group` của nó — +// dù `PluginManifest.group` tồn tại để phân biệt NGUỒN CÀI, `installer.ts` +// chặn cài một plugin nếu `id` đã bị một `group` khác chiếm (bắt gỡ bản cũ +// trước khi cài bản mới), nên tại registry chỉ CẦN VÀ CHỈ CÓ một bảng +// `seenIds` phẳng — y hệt trước khi khái niệm group ra đời. Xem +// `PluginManifest.group`'s doc comment cho lý do (usePluginSdkFor/ +// getPluginSdk gọi module-scope chỉ nhận được `id`, không có `group`, nên +// hai bản ghi cùng `id` coexist thật sẽ khiến lời gọi đó mập mờ). const seenIds = new Map(); const seenRoutes = new Map(); const seenOrders = new Map(); @@ -83,6 +91,10 @@ function registerManifest( const sdk = createPluginSdk(m); const record: PluginRecord = { ...m, + // `'core'` cho mọi plugin compile-time (26 file `src/plugins// + // plugin.ts` không khai field này) — `installer.ts` luôn khai rõ `group` + // cho plugin cài lúc chạy, nên `?? 'core'` không bao giờ chạm nhánh đó. + group: m.group ?? 'core', keywords: m.keywords ?? [], experimental: m.experimental ?? false, fullHeight: m.fullHeight ?? true, diff --git a/src/platform/sdk.test.ts b/src/platform/sdk.test.ts index 21c9dc0d..f2ae252e 100644 --- a/src/platform/sdk.test.ts +++ b/src/platform/sdk.test.ts @@ -91,6 +91,20 @@ describe('secrets', () => { }); }); +describe('group', () => { + it('không khai group trong manifest (mọi plugin compile-time) thì mặc định "core"', () => { + const sdk = createPluginSdk(plugin([])); + expect(sdk.group).toBe('core'); + }); + + it('có khai group (plugin cài lúc chạy) thì giữ nguyên, id KHÔNG bị đổi theo', () => { + const manifest = { ...plugin([]), group: 'official' }; + const sdk = createPluginSdk(manifest); + expect(sdk.id).toBe('demo'); + expect(sdk.group).toBe('official'); + }); +}); + describe('env', () => { it('phơi ra thông tin môi trường mà không cần quyền nào', () => { const sdk = createPluginSdk(plugin([])); diff --git a/src/platform/sdk.ts b/src/platform/sdk.ts index 8c3628f1..122db5b6 100644 --- a/src/platform/sdk.ts +++ b/src/platform/sdk.ts @@ -130,6 +130,9 @@ export interface PluginFiles { export interface PluginSdk { readonly id: string; + /** Nguồn cài — xem `PluginManifest.group`. Metadata, không ảnh hưởng khoá + * storage/audit (vẫn theo `id` một mình). */ + readonly group: string; readonly sdkVersion: string; readonly permissions: readonly PluginPermission[]; readonly env: PluginEnv; @@ -216,6 +219,7 @@ export function createPluginSdk(manifest: PluginManifest): PluginSdk { return { id, + group: manifest.group ?? 'core', sdkVersion: SDK_VERSION, permissions: manifest.permissions ?? [], env: { isTauri, isMac: IS_MAC, modKey: MOD_KEY }, diff --git a/src/platform/types.ts b/src/platform/types.ts index e3650e57..bf28b660 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -44,8 +44,38 @@ export type PluginPermission = | 'service'; export interface PluginManifest { - /** kebab-case, duy nhất toàn app. Đồng thời là khoá bật/tắt và namespace storage. */ + /** + * kebab-case — chuỗi CHÍNH PLUGIN TỰ KHAI và tự dùng để gọi lại chính mình + * qua `usePluginSdkFor(id)`/`getPluginSdk(id)` (nó không biết, và không nên + * biết, mình thuộc `group` nào lúc build). Đồng thời là khoá bật/tắt và + * namespace storage — GIỮ NGUYÊN Ý NGHĨA CŨ, không đổi bởi `group`. + * + * Chỉ cần duy nhất TRONG `group` của nó theo MÔ HÌNH — nhưng trên thực tế, + * `installer.ts` chặn cài một plugin nếu `id` đã bị một `group` KHÁC chiếm + * (bắt người dùng gỡ bản cũ trước), nên tại một thời điểm chỉ có ĐÚNG MỘT + * bản ghi mang một `id` cho trước tồn tại — `id` vẫn nhất quán duy nhất + * toàn app trên thực tế. Lý do: `usePluginSdkFor`/`getPluginSdk` chỉ nhận + * được đúng chuỗi `id` này (không có `group`) khi plugin tự gọi mình ở + * module-scope (không qua React context, vd store nền của Kafka/RabbitMQ) + * — cho hai `group` cùng `id` coexist thật sẽ khiến lời gọi đó không thể + * biết nên trả bản ghi nào, âm thầm gán nhầm storage/audit/quyền. + */ id: string; + /** + * Nguồn plugin được cài từ đâu — thuần METADATA, không tham gia tính khoá + * storage/audit (vẫn khoá theo `id` một mình, y hệt trước khi field này ra + * đời — không migrate gì). Dùng để: (1) sinh route cho plugin cài lúc chạy + * (`/installed//`), (2) hiển thị nguồn cài trong Settings, và + * (3) phân biệt "cài lại/cập nhật CÙNG nguồn" (hợp lệ) với "một `group` + * KHÁC cùng khai trùng `id`" (conflict, chặn cài — xem `installer.ts`). + * + * `"core"` cho 26 plugin compile-time (registry.ts TỰ GÁN khi thiếu — 26 + * file `src/plugins//plugin.ts` không cần sửa để khai field này), + * `"url"` cho plugin cài qua URL manifest dán tay (không market), hoặc + * chính `marketId` cho plugin cài qua market — `installer.ts` luôn khai rõ + * cho cả hai loại này, không dựa vào mặc định. + */ + group?: string; label: string; icon: LucideIcon; description: string; @@ -98,6 +128,8 @@ export interface PluginManifest { /** Manifest đã qua kiểm tra + các giá trị mặc định đã điền. */ export interface PluginRecord extends PluginManifest { + /** Không còn optional — `registerManifest` đã điền `'core'` khi thiếu. */ + group: string; keywords: string[]; experimental: boolean; fullHeight: boolean; diff --git a/src/platform/usePluginSdkFor.ts b/src/platform/usePluginSdkFor.ts index 9da6658f..78e7f386 100644 --- a/src/platform/usePluginSdkFor.ts +++ b/src/platform/usePluginSdkFor.ts @@ -15,6 +15,13 @@ const cache = new Map(); * Kết quả được nhớ theo id: SDK gắn danh tính plugin chứ không mang state gì, * nên một instance cho mỗi plugin là đủ — và giữ nguyên một tham chiếu giúp các * `useMemo([sdk])` phía trên không bị vô hiệu. + * + * Gọi ở MODULE SCOPE (không qua React context) nên chỉ nhận được đúng + * `pluginId` — không biết, và không cần biết, `group` nào đã cài nó. An toàn + * vì `installer.ts` chặn cài một plugin nếu `id` của nó đã bị một `group` + * KHÁC chiếm (bắt gỡ bản cũ trước) — tại một thời điểm chỉ có đúng một bản + * ghi mang `pluginId` này trong registry, nên `getPlugin(pluginId)` không + * mập mờ. Xem `PluginManifest.group` cho lý do đầy đủ. */ export function getPluginSdk(pluginId: string): PluginSdk { const existing = cache.get(pluginId);