Skip to content
Merged
25 changes: 16 additions & 9 deletions docs/plugin-sdk/02-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<marketId>-<id>`, route thành `/installed/<marketId>-<id>`) — 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/<group>/<id>`), 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`)

Expand Down
5 changes: 5 additions & 0 deletions src/components/ExtensionInstallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/components/SettingsExtensionInstaller.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/components/SettingsExtensionInstaller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/components/SettingsExtensions.pendingInstallTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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` —
Expand Down
11 changes: 6 additions & 5 deletions src/lib/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<group>/<id>`) và trong `by-market/<market_id>/` 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()}`,
Expand Down
1 change: 1 addition & 0 deletions src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
currentTargetTriple,
checkAllForUpdates,
recordKey,
assertNoConflictingInstall,
} from './installer';
export type {
RemotePluginManifest,
Expand Down
109 changes: 98 additions & 11 deletions src/platform/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
});

Expand Down
Loading
Loading