From 4566471192d5f34da3f2e0c765c25afb9217ecf1 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:27:55 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=8E=A8=20=E7=A7=BB=E9=99=A4=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=B8=82=E5=9C=BA=E5=88=86=E7=B1=BB=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- apps/web/src/lib/components/plugins/PluginsScreen.svelte | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/lib/components/plugins/PluginsScreen.svelte b/apps/web/src/lib/components/plugins/PluginsScreen.svelte index 6193bd0f..e62afb55 100644 --- a/apps/web/src/lib/components/plugins/PluginsScreen.svelte +++ b/apps/web/src/lib/components/plugins/PluginsScreen.svelte @@ -516,15 +516,10 @@ {#each groupedCatalogManifests as group (group.category)} {@const groupMeta = getPluginCategoryMeta(group.category)}
-
+

{groupMeta.label}

- - {hostT('plugins.builtin.count', { - count: group.entries.length - })} -
{#each group.entries as entry (entry.manifest.id)} From 7b435a4c77f8e3e695061ebfd96d0ea2f6db8b44 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:28:29 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=8E=A8=20=E7=B2=BE=E7=AE=80=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BD=93=E7=A7=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- apps/web/src/lib/components/plugins/PluginsScreen.svelte | 9 +++++---- apps/web/src/lib/i18n/host-messages/en.ts | 1 - apps/web/src/lib/i18n/host-messages/zh-cn.ts | 1 - apps/web/src/lib/storage/clear-app-data.test.ts | 5 +++++ apps/web/src/lib/utils/format-bytes.ts | 6 +++--- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/components/plugins/PluginsScreen.svelte b/apps/web/src/lib/components/plugins/PluginsScreen.svelte index e62afb55..03e232df 100644 --- a/apps/web/src/lib/components/plugins/PluginsScreen.svelte +++ b/apps/web/src/lib/components/plugins/PluginsScreen.svelte @@ -554,10 +554,11 @@ > {/if} {#if manifest.downloadSizeBytes !== undefined} - - {hostT('plugins.size', { - size: formatBytes(manifest.downloadSizeBytes) - })} + + + {formatBytes(manifest.downloadSizeBytes, 0)} {/if}
diff --git a/apps/web/src/lib/i18n/host-messages/en.ts b/apps/web/src/lib/i18n/host-messages/en.ts index a07c0032..d2b81428 100644 --- a/apps/web/src/lib/i18n/host-messages/en.ts +++ b/apps/web/src/lib/i18n/host-messages/en.ts @@ -146,7 +146,6 @@ export const en = { 'plugins.link.installing': 'Installing from link…', 'plugins.link.placeholder': 'https://example.com/plugin.manifest.json', 'plugins.link.source': 'Source: {origin}. Only install if you trust this source', - 'plugins.size': 'Size: {size}', 'plugins.notify.installed': 'Plugin "{pluginId}" installed and enabled', 'plugins.notify.uninstalled': 'Plugin "{pluginId}" uninstalled', 'plugins.notify.enabled': 'Plugin "{pluginId}" enabled', diff --git a/apps/web/src/lib/i18n/host-messages/zh-cn.ts b/apps/web/src/lib/i18n/host-messages/zh-cn.ts index 538e1121..9341ffa1 100644 --- a/apps/web/src/lib/i18n/host-messages/zh-cn.ts +++ b/apps/web/src/lib/i18n/host-messages/zh-cn.ts @@ -142,7 +142,6 @@ export const zhCn = { 'plugins.link.installing': '正在从链接安装…', 'plugins.link.placeholder': 'https://example.com/plugin.manifest.json', 'plugins.link.source': '来源:{origin},请确认信任该来源后再安装', - 'plugins.size': '大小:{size}', 'plugins.notify.installed': '插件「{pluginId}」已安装并启用', 'plugins.notify.uninstalled': '插件「{pluginId}」已卸载', 'plugins.notify.enabled': '已启用插件「{pluginId}」', diff --git a/apps/web/src/lib/storage/clear-app-data.test.ts b/apps/web/src/lib/storage/clear-app-data.test.ts index 2f486bff..f7e73e0a 100644 --- a/apps/web/src/lib/storage/clear-app-data.test.ts +++ b/apps/web/src/lib/storage/clear-app-data.test.ts @@ -39,4 +39,9 @@ describe('formatBytes', () => { expect(formatBytes(1536)).toBe('1.5 KB'); expect(formatBytes(2 * 1024 * 1024)).toBe('2.0 MB'); }); + + it('supports displaying rounded whole units', () => { + expect(formatBytes(1536, 0)).toBe('2 KB'); + expect(formatBytes(1.5 * 1024 * 1024, 0)).toBe('2 MB'); + }); }); diff --git a/apps/web/src/lib/utils/format-bytes.ts b/apps/web/src/lib/utils/format-bytes.ts index 6ab53de5..cd963990 100644 --- a/apps/web/src/lib/utils/format-bytes.ts +++ b/apps/web/src/lib/utils/format-bytes.ts @@ -1,5 +1,5 @@ -export function formatBytes(bytes: number): string { +export function formatBytes(bytes: number, fractionDigits = 1): string { if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(fractionDigits)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(fractionDigits)} MB`; } From e8a4c86b3e1e52efc07ad341c8295f57fd4680a4 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:12:05 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E9=A2=84=E5=AE=89=E8=A3=85=E6=8F=92=E4=BB=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- apps/web/src/lib/boot/profile-sync.test.ts | 8 +++----- .../lib/profile-codegen/profile-definitions.ts | 17 +++-------------- apps/web/src/lib/services/app-engine.ts | 2 +- .../official-plugins/official-plugin-service.ts | 2 +- packages/core/src/profile/profile.ts | 7 ++----- packages/core/tests/profile-system.test.ts | 13 +------------ 6 files changed, 11 insertions(+), 38 deletions(-) diff --git a/apps/web/src/lib/boot/profile-sync.test.ts b/apps/web/src/lib/boot/profile-sync.test.ts index f5745f17..2b2ba7f2 100644 --- a/apps/web/src/lib/boot/profile-sync.test.ts +++ b/apps/web/src/lib/boot/profile-sync.test.ts @@ -15,11 +15,9 @@ describe('profile and deployment boundaries', () => { expect( profile.preinstall.every((entry) => OFFICIAL_PLUGINS.some((p) => p.id === entry.id)) ).toBe(true); - expect( - profile.preinstall.some( - (entry) => entry.id === profile.defaultTheme.pluginId && entry.enabled !== false - ) - ).toBe(true); + expect(profile.preinstall.some((entry) => entry.id === profile.defaultTheme.pluginId)).toBe( + true + ); expect(profile.preinstall.some((entry) => entry.id === 'core-shell')).toBe(false); expect(profile.preinstall.some((entry) => entry.id === 'theme-arknights')).toBe(false); } diff --git a/apps/web/src/lib/profile-codegen/profile-definitions.ts b/apps/web/src/lib/profile-codegen/profile-definitions.ts index f20c5acf..b056ec6d 100644 --- a/apps/web/src/lib/profile-codegen/profile-definitions.ts +++ b/apps/web/src/lib/profile-codegen/profile-definitions.ts @@ -9,10 +9,7 @@ const DEFAULT_PROFILE: ChronosProfile = { description: '包含分享口令与标准备份能力', defaultTheme: { pluginId: 'theme-m3', themeId: 'm3-default' }, defaultImportSlot: 'share-link', - preinstall: [ - { id: 'theme-m3', enabled: true }, - { id: 'codec-share', enabled: true } - ] + preinstall: [{ id: 'theme-m3' }, { id: 'codec-share' }] }; const CQUT_PROFILE: ChronosProfile = { @@ -21,11 +18,7 @@ const CQUT_PROFILE: ChronosProfile = { description: '专为重庆理工大学定制,内置知行理工教务直连与校区专属节次', defaultTheme: { pluginId: 'theme-m3', themeId: 'm3-default' }, defaultImportSlot: 'cqut-online', - preinstall: [ - { id: 'theme-m3', enabled: true }, - { id: 'source-cqut', enabled: true }, - { id: 'codec-share', enabled: true } - ] + preinstall: [{ id: 'theme-m3' }, { id: 'source-cqut' }, { id: 'codec-share' }] }; const CQUT_OFFLINE_PROFILE: ChronosProfile = { @@ -35,11 +28,7 @@ const CQUT_OFFLINE_PROFILE: ChronosProfile = { defaultTheme: { pluginId: 'theme-m3', themeId: 'm3-default' }, defaultImportSlot: 'edu-html', deniedPluginServerActions: [{ pluginId: 'source-cqut', action: 'preview' }], - preinstall: [ - { id: 'theme-m3', enabled: true }, - { id: 'source-cqut', enabled: true }, - { id: 'codec-share', enabled: true } - ] + preinstall: [{ id: 'theme-m3' }, { id: 'source-cqut' }, { id: 'codec-share' }] }; export const CHRONOS_PROFILES: Record = { diff --git a/apps/web/src/lib/services/app-engine.ts b/apps/web/src/lib/services/app-engine.ts index b44a2b0a..df6ac617 100644 --- a/apps/web/src/lib/services/app-engine.ts +++ b/apps/web/src/lib/services/app-engine.ts @@ -205,7 +205,7 @@ function getMissingPreinstalls( const installed = new Map(service.listInstalled().map((record) => [record.manifest.id, record])); return profile.preinstall.flatMap((entry) => { const record = installed.get(entry.id); - return record && record.enabled === (entry.enabled !== false) ? [] : [entry.id]; + return record && record.enabled ? [] : [entry.id]; }); } diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts index 8ba09ec9..8887166d 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts @@ -151,7 +151,7 @@ export class OfficialPluginService implements Disposable { system: true, preinstall: { profileId: this.profile.profileId, - enabled: entry.enabled !== false, + enabled: true, config: entry.config } }); diff --git a/packages/core/src/profile/profile.ts b/packages/core/src/profile/profile.ts index 0f24ad00..01365fe4 100644 --- a/packages/core/src/profile/profile.ts +++ b/packages/core/src/profile/profile.ts @@ -2,7 +2,6 @@ import type { UserPreferences } from '../domain/preferences'; export interface PluginProfileConfig { id: string; - enabled?: boolean; config?: Record; } @@ -42,14 +41,12 @@ export function resolveLayeredPluginConfig>( export function validateProfile(profile: ChronosProfile): void { if (new Set(profile.preinstall.map((p) => p.id)).size !== profile.preinstall.length) throw new Error('Duplicate preinstall plugin ID'); - if (profile.preinstall.some((entry) => entry.enabled === false)) - throw new Error('Preinstalled plugins must be enabled'); const selection = profile.defaultTheme; if (!selection?.pluginId?.trim() || !selection.themeId?.trim()) throw new Error(`Profile ${profile.profileId} requires a default theme`); - if (!profile.preinstall.some((p) => p.id === selection.pluginId && p.enabled !== false)) + if (!profile.preinstall.some((p) => p.id === selection.pluginId)) throw new Error( - `Default theme provider ${selection.pluginId} must be enabled in profile ${profile.profileId}` + `Default theme provider ${selection.pluginId} must be preinstalled in profile ${profile.profileId}` ); if (profile.preferences && 'visualThemeId' in profile.preferences) throw new Error('Profile preferences must not set visualThemeId'); diff --git a/packages/core/tests/profile-system.test.ts b/packages/core/tests/profile-system.test.ts index 9a50aedb..ae631509 100644 --- a/packages/core/tests/profile-system.test.ts +++ b/packages/core/tests/profile-system.test.ts @@ -38,22 +38,11 @@ describe('profile validation', () => { defaultTheme: { pluginId: 'base', themeId: 'theme' }, preinstall: [{ id: 'base' }] }; - it('rejects a disabled ordinary preinstall', () => { - expect(() => - validateProfile({ - ...profile, - preinstall: [...profile.preinstall, { id: 'tool', enabled: false }] - }) - ).toThrow('must be enabled'); - }); - it('rejects missing, disabled and overridden defaults', () => { + it('rejects missing and overridden defaults', () => { expect(() => validateProfile({ ...profile, defaultTheme: undefined } as unknown as ChronosProfile) ).toThrow(); expect(() => validateProfile({ ...profile, preinstall: [] })).toThrow(); - expect(() => - validateProfile({ ...profile, preinstall: [{ id: 'base', enabled: false }] }) - ).toThrow(); expect(() => validateProfile({ ...profile, From ff4f4dc9fb70cc874d9f852c7359c87b643b9cf7 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:15:08 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=90=AF=E7=94=A8=E7=8A=B6=E6=80=81=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=85=BC=E5=AE=B9=E6=80=A7=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .../components/plugins/PluginsScreen.svelte | 36 ++------ apps/web/src/lib/i18n/host-messages/en.ts | 12 ++- apps/web/src/lib/i18n/host-messages/zh-cn.ts | 12 ++- apps/web/src/lib/services/app-engine.test.ts | 2 - apps/web/src/lib/services/app-engine.ts | 7 +- .../official-plugins/installed-store.test.ts | 25 ++++-- .../official-plugins/installed-store.ts | 13 +-- .../official-plugin-hmr.test.ts | 34 +------ .../official-plugins/official-plugin-hmr.ts | 12 +-- .../official-plugin-service.test.ts | 89 +++---------------- .../official-plugin-service.ts | 72 ++++----------- .../official-plugins/official-plugin-types.ts | 1 - .../official-plugins/plugin-asset-replacer.ts | 12 +-- .../plugin-operation-interleaving.test.ts | 40 +-------- .../official-plugins/preinstall-policy.ts | 4 +- .../runtime-activator.test.ts | 10 --- .../sync-installed-plugins.test.ts | 1 - .../theme-wallpaper-lifecycle.test.ts | 2 - .../official-plugins/theme-wallpaper.test.ts | 1 - tests/browser/pwa-update.spec.ts | 18 +--- 20 files changed, 94 insertions(+), 309 deletions(-) diff --git a/apps/web/src/lib/components/plugins/PluginsScreen.svelte b/apps/web/src/lib/components/plugins/PluginsScreen.svelte index 03e232df..7604c0b3 100644 --- a/apps/web/src/lib/components/plugins/PluginsScreen.svelte +++ b/apps/web/src/lib/components/plugins/PluginsScreen.svelte @@ -13,7 +13,6 @@ import { resolveLocaleMapText } from '@chronos/core'; import SegmentedControl from '$lib/components/ui/SegmentedControl.svelte'; import Button from '$lib/components/ui/Button.svelte'; - import Switch from '$lib/components/ui/Switch.svelte'; import Dialog from '$lib/components/ui/Dialog.svelte'; import BottomSheet from '$lib/components/ui/BottomSheet.svelte'; import LoadingIndicator from '$lib/components/ui/LoadingIndicator.svelte'; @@ -258,17 +257,13 @@ } } - async function handleToggleEnabled(pluginId: string, enabled: boolean) { + async function handleConfirmCompatibility(pluginId: string) { operatingPluginId = pluginId; try { - if (enabled) { - await officialPlugins.enable(pluginId); - } else { - await officialPlugins.disable(pluginId); - } + await officialPlugins.confirmHostCompatibility(pluginId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - snackbarKey('snackbar.toggle.failed', { message: msg }); + snackbarKey('snackbar.confirmCompatibility.failed', { message: msg }); } finally { operatingPluginId = null; } @@ -385,12 +380,7 @@ {@const meta = getPluginCategoryMeta(resolvePluginCatalogCategory(record.manifest))} {@const isBusy = operatingPluginId === record.manifest.id} {@const update = updateStatuses[record.manifest.id]} -
+
@@ -430,8 +420,8 @@ handleConfirmCompatibility(record.manifest.id)} + >{hostT('plugins.action.confirmCompatibility')} {:else if update.status !== 'downloading'} {/if} - {#if !officialPlugins.isPreinstalledPlugin(record.manifest.id)} - - {hostT('plugins.action.enable')} - - - handleToggleEnabled(record.manifest.id, checked === true)} - /> - {/if}
diff --git a/apps/web/src/lib/i18n/host-messages/en.ts b/apps/web/src/lib/i18n/host-messages/en.ts index d2b81428..5f65542b 100644 --- a/apps/web/src/lib/i18n/host-messages/en.ts +++ b/apps/web/src/lib/i18n/host-messages/en.ts @@ -75,7 +75,7 @@ export const en = { // snackbar 'snackbar.install.failed': 'Install failed: {message}', 'snackbar.uninstall.failed': 'Uninstall failed: {message}', - 'snackbar.toggle.failed': 'Action failed: {message}', + 'snackbar.confirmCompatibility.failed': 'Compatibility confirmation failed: {message}', 'snackbar.manifestRequired': 'Enter a manifest.json URL', 'snackbar.manifestInvalid': 'Enter a valid http or https manifest URL', @@ -116,15 +116,14 @@ export const en = { 'plugins.empty.browse': 'Browse the marketplace', 'plugins.badge.installed': 'Installed', 'plugins.action.settings': 'Settings', - 'plugins.action.enable': 'Enable', - 'plugins.builtin.defaultEnabled': 'Enabled by default', + 'plugins.action.confirmCompatibility': 'Confirm compatibility', 'plugins.action.install': 'Install', 'plugins.action.installing': 'Installing…', 'plugins.action.update': 'Update', 'plugins.update.pending': 'Waiting for a matching version', 'plugins.update.downloading': 'Downloading update', 'plugins.update.confirmation-required': - 'Paused after host update; enable to confirm compatibility', + 'Paused after app update; confirm compatibility to run this plugin', 'plugins.update.ready': 'Ready', 'plugins.update.failed': 'Update failed; retry', 'plugins.action.retry': 'Retry', @@ -146,10 +145,9 @@ export const en = { 'plugins.link.installing': 'Installing from link…', 'plugins.link.placeholder': 'https://example.com/plugin.manifest.json', 'plugins.link.source': 'Source: {origin}. Only install if you trust this source', - 'plugins.notify.installed': 'Plugin "{pluginId}" installed and enabled', + 'plugins.notify.installed': 'Plugin "{pluginId}" installed', 'plugins.notify.uninstalled': 'Plugin "{pluginId}" uninstalled', - 'plugins.notify.enabled': 'Plugin "{pluginId}" enabled', - 'plugins.notify.disabled': 'Plugin "{pluginId}" disabled', + 'plugins.notify.confirmed': 'Compatibility confirmed for plugin "{pluginId}"', 'plugins.config.title': 'Plugin settings — {name}', 'plugins.config.empty': 'This plugin has no configurable options', 'plugins.config.saving': 'Saving…', diff --git a/apps/web/src/lib/i18n/host-messages/zh-cn.ts b/apps/web/src/lib/i18n/host-messages/zh-cn.ts index 9341ffa1..a20ff41b 100644 --- a/apps/web/src/lib/i18n/host-messages/zh-cn.ts +++ b/apps/web/src/lib/i18n/host-messages/zh-cn.ts @@ -74,7 +74,7 @@ export const zhCn = { // snackbar 'snackbar.install.failed': '安装失败: {message}', 'snackbar.uninstall.failed': '卸载失败: {message}', - 'snackbar.toggle.failed': '操作失败: {message}', + 'snackbar.confirmCompatibility.failed': '兼容性确认失败: {message}', 'snackbar.manifestRequired': '请输入 manifest.json 链接', 'snackbar.manifestInvalid': '请输入有效的 http 或 https manifest 链接', @@ -114,14 +114,13 @@ export const zhCn = { 'plugins.empty.browse': '浏览插件市场', 'plugins.badge.installed': '已安装', 'plugins.action.settings': '设置', - 'plugins.action.enable': '启用', - 'plugins.builtin.defaultEnabled': '默认启用', + 'plugins.action.confirmCompatibility': '确认兼容并运行', 'plugins.action.install': '安装', 'plugins.action.installing': '安装中…', 'plugins.action.update': '更新', 'plugins.update.pending': '等待适配当前应用版本', 'plugins.update.downloading': '正在下载更新', - 'plugins.update.confirmation-required': '应用已更新,需手动启用以确认兼容性', + 'plugins.update.confirmation-required': '应用已更新,确认兼容后才能运行此插件', 'plugins.update.ready': '已就绪', 'plugins.update.failed': '更新失败,可重试', 'plugins.action.retry': '重试', @@ -142,10 +141,9 @@ export const zhCn = { 'plugins.link.installing': '正在从链接安装…', 'plugins.link.placeholder': 'https://example.com/plugin.manifest.json', 'plugins.link.source': '来源:{origin},请确认信任该来源后再安装', - 'plugins.notify.installed': '插件「{pluginId}」已安装并启用', + 'plugins.notify.installed': '已安装插件「{pluginId}」', 'plugins.notify.uninstalled': '插件「{pluginId}」已卸载', - 'plugins.notify.enabled': '已启用插件「{pluginId}」', - 'plugins.notify.disabled': '已停用插件「{pluginId}」', + 'plugins.notify.confirmed': '已确认插件「{pluginId}」兼容当前应用', 'plugins.config.title': '插件设置 - {name}', 'plugins.config.empty': '该插件无可配置项', 'plugins.config.saving': '保存中…', diff --git a/apps/web/src/lib/services/app-engine.test.ts b/apps/web/src/lib/services/app-engine.test.ts index 36525535..637eda2b 100644 --- a/apps/web/src/lib/services/app-engine.test.ts +++ b/apps/web/src/lib/services/app-engine.test.ts @@ -189,7 +189,6 @@ describe('app-engine bootstrap', () => { acceptedHostVersion: APP_VERSION, colorsJson: themeColorsJson, manifestUrl: 'https://example.com/theme-yumemita.manifest.json', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }; @@ -354,7 +353,6 @@ describe('theme preferences during deferred boot', () => { colorsUrl: '/broken.json' }, colorsJson: 'invalid json', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }; diff --git a/apps/web/src/lib/services/app-engine.ts b/apps/web/src/lib/services/app-engine.ts index df6ac617..2275b95d 100644 --- a/apps/web/src/lib/services/app-engine.ts +++ b/apps/web/src/lib/services/app-engine.ts @@ -107,9 +107,8 @@ async function bootstrapEnginePhase2(engine: ChronosEngine): Promise { service.listFailures().size > 0 || records.some( (record) => - record.enabled && - ((!record.manifest.colorsUrl && !service.isPluginActive(record.manifest.id)) || - record.manifest.themeId === preferred) + (!record.manifest.colorsUrl && !service.isPluginActive(record.manifest.id)) || + record.manifest.themeId === preferred ); if (!couldRecover) await engine.revertToDefaultThemes(); } @@ -205,7 +204,7 @@ function getMissingPreinstalls( const installed = new Map(service.listInstalled().map((record) => [record.manifest.id, record])); return profile.preinstall.flatMap((entry) => { const record = installed.get(entry.id); - return record && record.enabled ? [] : [entry.id]; + return record ? [] : [entry.id]; }); } diff --git a/apps/web/src/lib/services/official-plugins/installed-store.test.ts b/apps/web/src/lib/services/official-plugins/installed-store.test.ts index c60ab7c3..a7667bf8 100644 --- a/apps/web/src/lib/services/official-plugins/installed-store.test.ts +++ b/apps/web/src/lib/services/official-plugins/installed-store.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vite-plus/test'; import { ChronosEngine } from '@chronos/core'; import type { ChronosEnv } from '@chronos/core'; import { DEFAULT_USER_PREFERENCES } from '@chronos/core'; -import { OfficialPluginInstalledStore } from './installed-store'; +import { OfficialPluginInstalledStore, parseInstallationState } from './installed-store'; function createMockEnv() { const kv = new Map(); @@ -59,6 +59,19 @@ describe('OfficialPluginInstalledStore', () => { await engine.storage.getPluginData('core.official-plugins', 'installed_plugins') ).toEqual(invalid); }); + it('rejects obsolete enable state without migrating development data', () => { + expect(() => + parseInstallationState({ + records: [ + { manifest: { id: 'old' }, origin: { kind: 'user' }, enabled: false, installedAt: 1 } + ], + removed: [], + seeded: true, + revision: 0, + generation: '' + }) + ).toThrow('reset development data manually'); + }); it('notifies change listeners on persist', async () => { const listener = vi.fn(); store.onChanged(listener); @@ -75,7 +88,6 @@ describe('OfficialPluginInstalledStore', () => { bundleUrl: '/b.js', sha256: 'abc' }, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); @@ -92,7 +104,6 @@ describe('OfficialPluginInstalledStore', () => { await restarted.upsert({ manifest: { id: 'removed' } as never, origin: { kind: 'user' }, - enabled: true, installedAt: 1 }); await store.load(); @@ -105,7 +116,6 @@ describe('OfficialPluginInstalledStore', () => { const record = (id: string) => ({ manifest: { id } as never, origin: { kind: 'user' as const }, - enabled: false, installedAt: 1 }); await Promise.all([store.upsert(record('a')), other.upsert(record('b'))]); @@ -135,12 +145,11 @@ describe('OfficialPluginInstalledStore', () => { await store.upsert({ manifest: { id: 'a' } as never, origin: { kind: 'user' }, - enabled: false, installedAt: 1 }); const other = new OfficialPluginInstalledStore(engine); await other.startHost(host); - await other.setEnabled('a', true); + await other.upsert(other.find('a')!); const target = { ...host, buildId: 'c'.repeat(64) }; await expect( store.prepare({ @@ -159,13 +168,13 @@ describe('OfficialPluginInstalledStore', () => { token: 'ready', until: Date.now() + 10000 }); - await expect(other.setEnabled('a', false)).rejects.toThrow('Application update in progress'); + await expect(other.remove('a')).rejects.toThrow('Application update in progress'); const updated = new OfficialPluginInstalledStore(engine); await updated.startHost(target); await expect(store.startHost(host, host.buildId)).rejects.toThrow('Host generation changed'); await other.startHost(target, host.buildId); expect(other.prepared).toBeUndefined(); await expect(store.remove('a')).rejects.toThrow('Application update in progress'); - expect(updated.find('a')?.enabled).toBe(true); + expect(updated.find('a')).toBeDefined(); }); }); diff --git a/apps/web/src/lib/services/official-plugins/installed-store.ts b/apps/web/src/lib/services/official-plugins/installed-store.ts index bb1c8ada..8c21fd28 100644 --- a/apps/web/src/lib/services/official-plugins/installed-store.ts +++ b/apps/web/src/lib/services/official-plugins/installed-store.ts @@ -41,7 +41,7 @@ export function parseInstallationState(value: unknown): PluginInstallationState state.records.some( (record) => !record?.manifest?.id || - typeof record.enabled !== 'boolean' || + Object.hasOwn(record, 'enabled') || !( record.origin?.kind === 'user' || (record.origin?.kind === 'profile' && typeof record.origin.profileId === 'string') @@ -50,6 +50,9 @@ export function parseInstallationState(value: unknown): PluginInstallationState new Set(state.records.map((record) => record.manifest.id)).size !== state.records.length || (state.prepared && (!Array.isArray(state.prepared.records) || + state.prepared.records.some( + (record) => !record?.manifest?.id || Object.hasOwn(record, 'enabled') + ) || !state.prepared.target?.buildId || !Number.isSafeInteger(state.prepared.revision) || typeof state.prepared.token !== 'string' || @@ -168,8 +171,7 @@ export class OfficialPluginInstalledStore { ) { for (const record of state.prepared.records) { const index = state.records.findIndex((old) => old.manifest.id === record.manifest.id); - if (index >= 0) - state.records[index] = { ...record, enabled: state.records[index].enabled }; + if (index >= 0) state.records[index] = record; } state.revision++; delete state.prepared; @@ -219,12 +221,11 @@ export class OfficialPluginInstalledStore { state.removed = [...new Set([...state.removed, id])]; }); } - async setEnabled(id: string, enabled: boolean, acceptedHostVersion?: string) { + async acceptHostVersion(id: string, hostVersion: string) { await this.mutate((state) => { const record = state.records.find((record) => record.manifest.id === id); if (!record) throw new Error(`Plugin not installed: ${id}`); - record.enabled = enabled; - if (acceptedHostVersion) record.acceptedHostVersion = acceptedHostVersion; + record.acceptedHostVersion = hostVersion; record.revision = (record.revision ?? 0) + 1; }); } diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-hmr.test.ts b/apps/web/src/lib/services/official-plugins/official-plugin-hmr.test.ts index 460ee100..88e6c683 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-hmr.test.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-hmr.test.ts @@ -31,7 +31,6 @@ describe('official-plugin-hmr', () => { cssCode: '.test { color: red; }', colorsJson: null, iconThemeJson: null, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1000 }; @@ -93,26 +92,6 @@ describe('official-plugin-hmr', () => { ); }); - it('updates disabled plugin in store without activating runtime', async () => { - installedMap.set('tool-test', { ...sampleRecord, enabled: false }); - - await handlePluginHmr(mockService as OfficialPluginService, mockEngine as ChronosEngine, { - id: 'tool-test', - rev: 'abc2', - costMs: '8.0', - code: 'new code', - cssCode: null, - colorsJson: null, - iconThemeJson: null - }); - - expect(applyHotUpdateFn).toHaveBeenCalled(); - expect(notifyFn).toHaveBeenCalledWith( - expect.stringContaining('[HMR] 插件 tool-test 已更新 (8.0ms,未启用)'), - 'info' - ); - }); - it('notifies when plugin is not installed', async () => { await handlePluginHmr(mockService as OfficialPluginService, mockEngine as ChronosEngine, { id: 'unknown-plugin', @@ -147,7 +126,6 @@ describe('official-plugin-hmr', () => { cssCode: null, colorsJson: '{"id":"theme-test"}', iconThemeJson: null, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1000 }; @@ -307,7 +285,7 @@ describe('official-plugin-hmr', () => { expect(applyHotUpdateFn).toHaveBeenCalledTimes(2); lifecycle.dispose(); }); - it('failure does not block later updates and queued work rechecks uninstall and enabled state', async () => { + it('failure does not block later updates and queued work rechecks uninstall', async () => { const service = mockService as OfficialPluginService; const engine = mockEngine as ChronosEngine; setupPluginHmr(service, engine); @@ -326,19 +304,11 @@ describe('official-plugin-hmr', () => { await failure; await good; expect(installedMap.get('tool-test')?.code).toBe('good'); - const disabled = enqueuePluginHmr(service, engine, { - ...payload, - rev: 'disabled', - code: 'disabled' - }); - installedMap.set('tool-test', { ...installedMap.get('tool-test')!, enabled: false }); - await disabled; - expect(installedMap.get('tool-test')).toMatchObject({ enabled: false, code: 'disabled' }); const removed = enqueuePluginHmr(service, engine, { ...payload, rev: 'removed' }); installedMap.delete('tool-test'); await removed; expect(installedMap.has('tool-test')).toBe(false); - expect(applyHotUpdateFn).toHaveBeenCalledTimes(3); + expect(applyHotUpdateFn).toHaveBeenCalledTimes(2); }); it('dispose aborts bootstrap and running work, drops queued work and waits for rollback before a replacement session', async () => { const service = mockService as OfficialPluginService; diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-hmr.ts b/apps/web/src/lib/services/official-plugins/official-plugin-hmr.ts index bb67975c..5bb6e196 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-hmr.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-hmr.ts @@ -157,23 +157,17 @@ export async function handlePluginHmr( options?.signal?.throwIfAborted(); const updated = await service.applyHotUpdate(data, { signal: options?.signal }); if (options?.signal?.aborted) return false; - if (updated.enabled && updated.manifest.type === 'theme') { + if (updated.manifest.type === 'theme') { const manifest = updated.manifest as PluginManifest & { themeId?: string }; const active = engine.state.activeThemeId; if (active && (active === id || manifest.themeId === active)) engine.setTheme(active); } - if (!options?.silent) - engine.notify( - updated.enabled - ? `[HMR] 插件 ${id} 已热重载 (${costMs}ms)` - : `[HMR] 插件 ${id} 已更新 (${costMs}ms,未启用)`, - 'info' - ); + if (!options?.silent) engine.notify(`[HMR] 插件 ${id} 已热重载 (${costMs}ms)`, 'info'); return true; } catch (err) { if (options?.signal?.aborted) return false; const error = err instanceof Error ? err : new Error(String(err)); - const action = existing.enabled ? '热重载' : '热更新'; + const action = '热重载'; console.error(`[Plugin HMR] ${action} ${id} 失败:`, error); engine.notify(`[HMR] ${action} ${id} 失败: ${error.message}`, 'error'); return false; diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts b/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts index f4c6b698..a9b81314 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts @@ -313,7 +313,6 @@ describe('OfficialPluginService', () => { manifest, acceptedHostVersion: '0.4.1', code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -464,7 +463,6 @@ describe('OfficialPluginService', () => { code: SAMPLE_BUNDLE, cssCode: '.x{color:red}', manifestUrl: OFFICIAL_MANIFEST_URL, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -518,7 +516,6 @@ describe('OfficialPluginService', () => { manifest: staleManifest, code: SAMPLE_BUNDLE, manifestUrl: OFFICIAL_MANIFEST_URL, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -581,7 +578,6 @@ describe('OfficialPluginService', () => { manifest, code: SAMPLE_BUNDLE, manifestUrl, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -598,7 +594,7 @@ describe('OfficialPluginService', () => { expect(httpRequest).not.toHaveBeenCalled(); expect(engine.isPluginLoaded('test-plugin')).toBe(false); expect(service.getUpdateStatus('test-plugin')?.status).toBe('confirmation-required'); - await service.enable('test-plugin'); + await service.confirmHostCompatibility('test-plugin'); expect(engine.isPluginLoaded('test-plugin')).toBe(true); expect(service.getInstalled('test-plugin')?.acceptedHostVersion).toBe('0.4.1'); }); @@ -624,7 +620,6 @@ describe('OfficialPluginService', () => { manifest: staleManifest, code: SAMPLE_BUNDLE, manifestUrl: OFFICIAL_MANIFEST_URL, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -667,7 +662,6 @@ describe('OfficialPluginService', () => { manifest: staleManifest, code: SAMPLE_BUNDLE, manifestUrl: OFFICIAL_MANIFEST_URL, - enabled: true, origin: { kind: 'user' as const }, installedAt: Date.now() } @@ -730,7 +724,6 @@ describe('OfficialPluginService', () => { manifest, manifestUrl: OFFICIAL_MANIFEST_URL, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' }, installedAt: 1 }); @@ -762,7 +755,7 @@ describe('OfficialPluginService', () => { service.dispose(); }); - it('prepares disabled official plugins for the next Web host without executing their code', async () => { + it('prepares installed official plugins for the next Web host without executing their code', async () => { const hash = await engine.runtime.sha256(SAMPLE_BUNDLE); const manifest: PluginManifest = { id: 'test-plugin', @@ -781,7 +774,6 @@ describe('OfficialPluginService', () => { { manifest, code: SAMPLE_BUNDLE, - enabled: false, origin: { kind: 'user' }, installedAt: 1, manifestUrl: OFFICIAL_MANIFEST_URL @@ -821,12 +813,14 @@ describe('OfficialPluginService', () => { }); expect(load).not.toHaveBeenCalled(); expect(service.getInstalled('test-plugin')?.manifest.version).toBe('0.4.1'); - expect(service.installationStore.prepared?.records[0].enabled).toBe(false); - await expect(service.enable('test-plugin')).rejects.toThrow('Application update in progress'); + expect(service.installationStore.prepared?.records).toHaveLength(1); + await expect(service.installationStore.remove('test-plugin')).rejects.toThrow( + 'Application update in progress' + ); expect(load).not.toHaveBeenCalled(); await service.installationStore.startHost(target); expect(service.getInstalled('test-plugin')?.manifest.version).toBe('0.4.2'); - expect(service.getInstalled('test-plugin')?.enabled).toBe(false); + expect(service.getInstalled('test-plugin')).toBeDefined(); }); it('installs plugin through installQueue with progress and state transitions', async () => { @@ -897,7 +891,7 @@ describe('OfficialPluginService', () => { expect(service.isPluginActive('test-plugin')).toBe(true); }); - it('updates disabled plugin assets through applyHotUpdate without activating runtime', async () => { + it('updates plugin assets through applyHotUpdate while preserving its data', async () => { const hash = await engine.env.runtime.sha256(SAMPLE_BUNDLE); const manifest: PluginManifest = { id: 'test-plugin', @@ -914,7 +908,6 @@ describe('OfficialPluginService', () => { httpRequest.mockResolvedValueOnce(httpResponse({ text: async () => SAMPLE_BUNDLE })); await service.install(manifest); - await service.disable('test-plugin'); await engine.storage.setPluginData('test-plugin', PLUGIN_CONFIG_STORAGE_KEY, { choice: 'kept' }); @@ -930,12 +923,11 @@ describe('OfficialPluginService', () => { }); expect(updated.code).toBe(updatedBundle); - expect(updated.enabled).toBe(false); expect(await engine.storage.getPluginData('test-plugin', PLUGIN_CONFIG_STORAGE_KEY)).toEqual({ choice: 'kept' }); expect(await engine.storage.getPluginData('test-plugin', 'private')).toEqual({ draft: 'kept' }); - expect(service.isPluginActive('test-plugin')).toBe(false); + expect(service.isPluginActive('test-plugin')).toBe(true); }); it('rolls back runtime when an upgrade install is aborted after deactivation', async () => { @@ -1141,56 +1133,6 @@ describe('profile preinstallation lifecycle', () => { engine.dispose(); }); - it('keeps failed enables disabled and allows retry', async () => { - const { engine, service, profile, tool } = await setupProfile(); - await service.prepareProfile({ - ...profile, - preinstall: [{ id: profile.defaultTheme.pluginId }] - }); - await service.install(tool); - await service.disable(tool.id); - const load = vi - .spyOn(engine, 'loadPlugin') - .mockRejectedValueOnce(new Error('activation failed')); - await expect(service.enable(tool.id)).rejects.toThrow('activation failed'); - expect(service.getInstalled(tool.id)?.enabled).toBe(false); - expect(service.isPluginActive(tool.id)).toBe(false); - const store = new OfficialPluginInstalledStore(engine); - await store.load(); - expect(store.find(tool.id)?.enabled).toBe(false); - await service.enable(tool.id); - expect(service.isPluginActive(tool.id)).toBe(true); - expect(service.getInstalled(tool.id)?.enabled).toBe(true); - load.mockRestore(); - service.dispose(); - engine.dispose(); - }); - - it('rolls back activation when persisting the enabled flag fails', async () => { - const { engine, service, profile, tool } = await setupProfile(); - await service.prepareProfile({ - ...profile, - preinstall: [{ id: profile.defaultTheme.pluginId }] - }); - await service.install(tool); - await service.disable(tool.id); - const load = vi - .spyOn(engine.storage, 'setPluginData') - .mockRejectedValueOnce(new Error('storage failed')); - await expect(service.enable(tool.id)).rejects.toThrow('storage failed'); - expect(service.getInstalled(tool.id)?.enabled).toBe(false); - expect(service.isPluginActive(tool.id)).toBe(false); - const store = new OfficialPluginInstalledStore(engine); - await store.load(); - expect(store.find(tool.id)?.enabled).toBe(false); - await service.enable(tool.id); - expect(service.isPluginActive(tool.id)).toBe(true); - expect(service.getInstalled(tool.id)?.enabled).toBe(true); - load.mockRestore(); - service.dispose(); - engine.dispose(); - }); - it('boots a non-M3 default first and shares records with market management', async () => { const { engine, service } = await setupProfile(); expect(engine.state.activeThemeId).toBe('custom-default'); @@ -1202,7 +1144,6 @@ describe('profile preinstallation lifecycle', () => { profileId: 'custom' }); expect(engine.getPluginContext('test-plugin').config.answer).toBe(42); - await expect(service.disable('custom-theme')).rejects.toThrow('required'); await expect(service.uninstall('custom-theme')).rejects.toThrow('required'); service.dispose(); engine.dispose(); @@ -1210,14 +1151,12 @@ describe('profile preinstallation lifecycle', () => { it('restores required installs and configuration, and releases plugins removed from the profile', async () => { const { engine, service, profile, tool } = await setupProfile(); await service.init(); - await expect(service.disable(tool.id)).rejects.toThrow('required'); await expect(service.uninstall(tool.id)).rejects.toThrow('required'); const store = new OfficialPluginInstalledStore(engine); await store.load(); await store.upsert({ ...service.getInstalled(tool.id)!, - origin: { kind: 'user' }, - enabled: false + origin: { kind: 'user' } }); await engine.storage.setPluginData(tool.id, '__config__', { answer: 99 }); service.dispose(); @@ -1227,22 +1166,20 @@ describe('profile preinstallation lifecycle', () => { preinstall: [{ id: 'custom-theme' }, { id: tool.id, config: { answer: 0 } }] }); await second.init(); - expect(second.getInstalled(tool.id)?.enabled).toBe(true); + expect(second.getInstalled(tool.id)).toBeDefined(); expect(second.isPreinstalledPlugin(tool.id)).toBe(true); expect(second.getInstalled(tool.id)?.origin).toEqual({ kind: 'user' }); await expect(second.uninstall(tool.id)).rejects.toThrow('required'); expect(second.getInstalled(tool.id)?.initialConfig).toEqual({ answer: 42 }); - await second.enable(tool.id); expect(engine.getPluginContext(tool.id).config.answer).toBe(99); await second.prepareProfile({ ...profile, preinstall: [{ id: 'custom-theme' }] }); expect(second.isPreinstalledPlugin(tool.id)).toBe(false); - await second.disable(tool.id); await second.uninstall(tool.id); second.dispose(); const third = createService(engine); await third.prepareProfile(profile); await third.init(); - expect(third.getInstalled(tool.id)?.enabled).toBe(true); + expect(third.getInstalled(tool.id)).toBeDefined(); const state = await engine.storage.getPluginData<{ removed: string[] }>( OFFICIAL_PLUGINS_PLUGIN_ID, INSTALLED_STORAGE_KEY @@ -1276,7 +1213,7 @@ describe('profile preinstallation lifecycle', () => { }); const next = createService(engine); await next.prepareProfile(profile); - expect(next.getInstalled(theme.id)?.enabled).toBe(true); + expect(next.getInstalled(theme.id)).toBeDefined(); expect(engine.defaultThemeId).toBe('custom-default'); next.dispose(); engine.dispose(); diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts index 8887166d..601f90a1 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts @@ -126,10 +126,9 @@ export class OfficialPluginService implements Disposable { const cached = this.installedStore.find(id); if (cached && this.isCompatible(cached)) { try { - await this.runtimeActivator.activate({ ...cached, enabled: true }); + await this.runtimeActivator.activate(cached); this.engine.validateDefaultTheme(profile.defaultTheme); - if (!cached.enabled || this.installedStore.getRemoved().includes(id)) - await this.installedStore.setEnabled(id, true); + if (this.installedStore.getRemoved().includes(id)) await this.installedStore.upsert(cached); } catch (error) { console.error('[preinstall] Default cache could not activate', error); await this.installPreinstall(id); @@ -151,7 +150,6 @@ export class OfficialPluginService implements Disposable { system: true, preinstall: { profileId: this.profile.profileId, - enabled: true, config: entry.config } }); @@ -168,8 +166,7 @@ export class OfficialPluginService implements Disposable { const cached = this.installedStore.find(entry.id); if (cached && this.isCompatible(cached)) { try { - await this.runtimeActivator.activate({ ...cached, enabled: true }); - await this.installedStore.setEnabled(entry.id, true); + await this.runtimeActivator.activate(cached); } catch { await this.installPreinstall(entry.id); } @@ -181,8 +178,7 @@ export class OfficialPluginService implements Disposable { } for (const id of this.installedStore.getRemoved()) { const record = this.installedStore.find(id); - if (record?.enabled && this.isPreinstalledPlugin(id)) - await this.installedStore.upsert(record); + if (record && this.isPreinstalledPlugin(id)) await this.installedStore.upsert(record); } this.installedStore.notify(); } @@ -339,7 +335,7 @@ export class OfficialPluginService implements Disposable { if (this.operations.isBusy(id)) continue; const record = this.installedStore.find(id); if ( - !record?.enabled || + !record || !this.isCompatible(record) || this.activeVersions.get(id) !== record.revision ) { @@ -458,7 +454,7 @@ export class OfficialPluginService implements Disposable { }); continue; } - if (record.enabled && !this.runtimeActivator.isActive(record.manifest.id)) { + if (!this.runtimeActivator.isActive(record.manifest.id)) { try { await this.runtimeActivator.activate(record); this.failures.delete(record.manifest.id); @@ -619,7 +615,7 @@ export class OfficialPluginService implements Disposable { validate: required ? () => this.engine.validateDefaultTheme(required) : undefined }, next, - { ...options, forceEnabled: this.isPreinstalledPlugin(candidate.manifest.id) } + options ); result = required ? await this.engine.withPluginReplacement(candidate.manifest.id, replace) @@ -634,11 +630,10 @@ export class OfficialPluginService implements Disposable { if (previous?.wallpaperAssetId && previous.wallpaperAssetId !== id) { await this.images.delete(previous.wallpaperAssetId).catch(console.error); } - if (result.enabled) - this.activeVersions.set( - result.manifest.id, - this.installedStore.find(result.manifest.id)?.revision - ); + this.activeVersions.set( + result.manifest.id, + this.installedStore.find(result.manifest.id)?.revision + ); this.updateStatuses.set(result.manifest.id, { status: 'ready' }); this.failures.delete(result.manifest.id); return result; @@ -669,7 +664,7 @@ export class OfficialPluginService implements Disposable { options?: { silent?: boolean; system?: boolean; - preinstall?: { profileId: string; enabled: boolean; config?: Record }; + preinstall?: { profileId: string; config?: Record }; signal?: AbortSignal; onProgress?: (progress: { stage: PluginInstallStage; @@ -730,7 +725,6 @@ export class OfficialPluginService implements Disposable { iconThemeJson: assets.iconThemeJson ?? null, cssCode: assets.cssCode ?? null, manifestUrl: manifestUrl ?? existingSnapshot?.manifestUrl, - enabled: existingSnapshot?.enabled ?? options?.preinstall?.enabled ?? true, installedAt: existingSnapshot?.installedAt ?? Date.now() }; @@ -778,7 +772,7 @@ export class OfficialPluginService implements Disposable { ); } - async enable(pluginId: string): Promise { + async confirmHostCompatibility(pluginId: string): Promise { return this.operations.run(pluginId, async ({ signal }) => { await this.installedStore.load(); this.lifecycle.signal.throwIfAborted(); @@ -789,24 +783,14 @@ export class OfficialPluginService implements Disposable { if (!record) { throw new Error(`Plugin not installed: ${pluginId}`); } - if (record.enabled && this.runtimeActivator.isActive(pluginId)) return; - - if ( - isOfficialCatalogManifestUrl(record.manifestUrl, pluginId) && - record.manifest.version !== this.hostVersion - ) { - await this.installedStore.setEnabled(pluginId, true, this.hostVersion); - await this.retryPendingUpdates(); - return; - } - await this.runtimeActivator.activate({ ...record, enabled: true }); + if (isOfficialCatalogManifestUrl(record.manifestUrl, pluginId)) + throw new Error('Official plugins do not require compatibility confirmation'); + if (record.acceptedHostVersion === this.hostVersion) return; + await this.runtimeActivator.activate(record); try { this.lifecycle.signal.throwIfAborted(); signal.throwIfAborted(); - await this.installedStore.upsert( - { ...record, enabled: true, acceptedHostVersion: this.hostVersion }, - record.revision ?? -1 - ); + await this.installedStore.acceptHostVersion(pluginId, this.hostVersion); this.activeVersions.set(pluginId, this.installedStore.find(pluginId)?.revision); this.updateStatuses.set(pluginId, { status: 'ready' }); this.installedStore.notify(); @@ -814,28 +798,10 @@ export class OfficialPluginService implements Disposable { await this.runtimeActivator.deactivate(pluginId, { revertThemes: true }); throw error; } - this.engine.notify(hostT('plugins.notify.enabled', { pluginId }), 'info'); + this.engine.notify(hostT('plugins.notify.confirmed', { pluginId }), 'info'); }); } - async disable(pluginId: string): Promise { - this.assertUserRemoval(pluginId); - return this.operations.run( - pluginId, - async ({ signal }) => { - const record = this.installedStore.find(pluginId); - if (!record) { - throw new Error(`Plugin not installed: ${pluginId}`); - } - signal.throwIfAborted(); - await this.installedStore.setEnabled(pluginId, false); - await this.runtimeActivator.deactivate(pluginId, { revertThemes: true }); - this.engine.notify(hostT('plugins.notify.disabled', { pluginId }), 'info'); - }, - { cancelExisting: true } - ); - } - async getPluginConfig>(pluginId: string): Promise { return this.engine.storage.getPluginData(pluginId, PLUGIN_CONFIG_STORAGE_KEY); } diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-types.ts b/apps/web/src/lib/services/official-plugins/official-plugin-types.ts index 4b28ad65..e0244baf 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-types.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-types.ts @@ -15,7 +15,6 @@ export interface InstalledOfficialPluginRecord { iconThemeJson?: string | null; cssCode?: string | null; manifestUrl?: string; - enabled: boolean; installedAt: number; } diff --git a/apps/web/src/lib/services/official-plugins/plugin-asset-replacer.ts b/apps/web/src/lib/services/official-plugins/plugin-asset-replacer.ts index f800a2d5..c8960ffa 100644 --- a/apps/web/src/lib/services/official-plugins/plugin-asset-replacer.ts +++ b/apps/web/src/lib/services/official-plugins/plugin-asset-replacer.ts @@ -11,7 +11,6 @@ export interface PluginAssetReplacerDeps { export interface ReplacePluginAssetsOptions { preserveInstalledAt?: boolean; - forceEnabled?: boolean; revertThemesOnDeactivate?: boolean; signal?: AbortSignal; } @@ -27,11 +26,10 @@ export async function replacePluginAssets( ): Promise { const pluginId = candidate.manifest.id; const existing = deps.installedStore.find(pluginId); - const hadActiveRuntime = Boolean(existing?.enabled && deps.runtimeActivator.isActive(pluginId)); + const hadActiveRuntime = deps.runtimeActivator.isActive(pluginId); const record: InstalledOfficialPluginRecord = { ...candidate, - enabled: options?.forceEnabled || (existing?.enabled ?? candidate.enabled), installedAt: options?.preserveInstalledAt && existing ? existing.installedAt @@ -68,7 +66,7 @@ export async function replacePluginAssets( try { options?.signal?.throwIfAborted?.(); - if (existing?.enabled) { + if (existing && hadActiveRuntime) { await deps.runtimeActivator.deactivate(pluginId, { revertThemes: options?.revertThemesOnDeactivate ?? false }); @@ -77,10 +75,8 @@ export async function replacePluginAssets( options?.signal?.throwIfAborted?.(); - if (record.enabled) { - runtimeTouched = true; - await deps.runtimeActivator.activate(record); - } + runtimeTouched = true; + await deps.runtimeActivator.activate(record); options?.signal?.throwIfAborted?.(); diff --git a/apps/web/src/lib/services/official-plugins/plugin-operation-interleaving.test.ts b/apps/web/src/lib/services/official-plugins/plugin-operation-interleaving.test.ts index ec705f77..05699eb4 100644 --- a/apps/web/src/lib/services/official-plugins/plugin-operation-interleaving.test.ts +++ b/apps/web/src/lib/services/official-plugins/plugin-operation-interleaving.test.ts @@ -219,13 +219,13 @@ describe('PluginOperationCoordinator interleaving in OfficialPluginService', () await new Promise((r) => setTimeout(r, 10)); } - // disable aborts current operations for test-plugin - const disablePromise = service.disable('test-plugin').catch(() => { + // uninstall aborts current operations for test-plugin + const uninstallPromise = service.uninstall('test-plugin').catch(() => { // expected to throw Plugin not installed since it wasn't yet installed }); await expect(installPromise).rejects.toThrow(/abort/i); - await disablePromise; + await uninstallPromise; expect(service.getInstalled('test-plugin')).toBeUndefined(); expect(engine.isPluginLoaded('test-plugin')).toBe(false); @@ -325,38 +325,4 @@ describe('PluginOperationCoordinator interleaving in OfficialPluginService', () expect(engine.isPluginLoaded('plugin-a')).toBe(true); expect(engine.isPluginLoaded('plugin-b')).toBe(true); }); - - it('serializes operations on the same plugin in FIFO order', async () => { - const hash = await engine.env.runtime.sha256(SAMPLE_BUNDLE); - const manifest: PluginManifest = { - id: 'test-plugin', - name: { 'zh-CN': 'Test' }, - version: '1.0.0', - description: { 'zh-CN': 'Test' }, - author: 'Chronos', - type: 'tool', - toolGroup: 'utility', - bundleFormat: 'esm', - bundleUrl: '/test.bundle.js', - sha256: hash - }; - - httpRequest.mockResolvedValue(httpResponse({ text: async () => SAMPLE_BUNDLE })); - await service.install(manifest, '/test.manifest.json'); - - const executionOrder: string[] = []; - - const p1 = service.disable('test-plugin').then(() => { - executionOrder.push('disable'); - }); - const p2 = service.enable('test-plugin').then(() => { - executionOrder.push('enable'); - }); - - await Promise.all([p1, p2]); - - expect(executionOrder).toEqual(['disable', 'enable']); - expect(service.getInstalled('test-plugin')?.enabled).toBe(true); - expect(engine.isPluginLoaded('test-plugin')).toBe(true); - }); }); diff --git a/apps/web/src/lib/services/official-plugins/preinstall-policy.ts b/apps/web/src/lib/services/official-plugins/preinstall-policy.ts index 999292ab..3c0b5050 100644 --- a/apps/web/src/lib/services/official-plugins/preinstall-policy.ts +++ b/apps/web/src/lib/services/official-plugins/preinstall-policy.ts @@ -1,11 +1,11 @@ import type { ChronosProfile } from '@chronos/core'; import type { InstalledOfficialPluginRecord } from './official-plugin-types'; -/** Current preinstalls must be present and enabled, regardless of past removals. */ +/** Current preinstalls must be present, regardless of past removals. */ export function planPreinstall( profile: ChronosProfile, installed: readonly InstalledOfficialPluginRecord[] ) { return profile.preinstall.filter( - (entry) => !installed.some((record) => record.manifest.id === entry.id && record.enabled) + (entry) => !installed.some((record) => record.manifest.id === entry.id) ); } diff --git a/apps/web/src/lib/services/official-plugins/runtime-activator.test.ts b/apps/web/src/lib/services/official-plugins/runtime-activator.test.ts index 5ea877dc..76062cf0 100644 --- a/apps/web/src/lib/services/official-plugins/runtime-activator.test.ts +++ b/apps/web/src/lib/services/official-plugins/runtime-activator.test.ts @@ -89,7 +89,6 @@ describe('OfficialPluginRuntimeActivator', () => { sha256: 'different-hash' } as never, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' }, installedAt: 1 }) @@ -125,7 +124,6 @@ describe('OfficialPluginRuntimeActivator', () => { variants: { light: { colors: colors.light }, dark: { colors: colors.dark } } }), code: `export default { id: 'hybrid', apply(ctx) { ctx.registerSlot('theme.definition', { id: 'hybrid-theme', name: 'Hybrid', workbenchColors: ${JSON.stringify(mismatch ? { light: {}, dark: {} } : colors)}, resolveWallpaperColors() { return { workbenchColors: {} }; } }); } };`, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }; @@ -158,7 +156,6 @@ describe('OfficialPluginRuntimeActivator', () => { colorsSha256: 'hash' }, colorsJson: THEME_COLORS_JSON, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); @@ -182,7 +179,6 @@ describe('OfficialPluginRuntimeActivator', () => { sha256: 'hash' }, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }) @@ -241,7 +237,6 @@ describe('OfficialPluginRuntimeActivator', () => { }, code: SAMPLE_BUNDLE, cssCode: '.x{color:red}', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }) @@ -311,7 +306,6 @@ describe('OfficialPluginRuntimeActivator', () => { }, code: SAMPLE_BUNDLE, cssCode: '.x{color:red}', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); @@ -335,7 +329,6 @@ describe('OfficialPluginRuntimeActivator', () => { sha256: 'hash' }, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); @@ -362,7 +355,6 @@ describe('OfficialPluginRuntimeActivator', () => { sha256: 'hash' }, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); @@ -394,7 +386,6 @@ describe('OfficialPluginRuntimeActivator', () => { }, colorsJson: THEME_COLORS_JSON, iconThemeJson: '{"id":"icon-test","icons":{}}', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }) @@ -421,7 +412,6 @@ describe('OfficialPluginRuntimeActivator', () => { sha256: 'hash' }, code: SAMPLE_BUNDLE, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); diff --git a/apps/web/src/lib/services/official-plugins/sync-installed-plugins.test.ts b/apps/web/src/lib/services/official-plugins/sync-installed-plugins.test.ts index 2936cf24..7b64cf44 100644 --- a/apps/web/src/lib/services/official-plugins/sync-installed-plugins.test.ts +++ b/apps/web/src/lib/services/official-plugins/sync-installed-plugins.test.ts @@ -12,7 +12,6 @@ function record( ): InstalledOfficialPluginRecord { return { code: null, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1, ...overrides diff --git a/apps/web/src/lib/services/official-plugins/theme-wallpaper-lifecycle.test.ts b/apps/web/src/lib/services/official-plugins/theme-wallpaper-lifecycle.test.ts index aafa14aa..58557aa8 100644 --- a/apps/web/src/lib/services/official-plugins/theme-wallpaper-lifecycle.test.ts +++ b/apps/web/src/lib/services/official-plugins/theme-wallpaper-lifecycle.test.ts @@ -33,7 +33,6 @@ async function setup() { const store = new OfficialPluginInstalledStore(engine); const initial = { manifest, - enabled: true, origin: { kind: 'user' as const }, installedAt: 1, wallpaperAssetId: 'old-image' @@ -78,7 +77,6 @@ describe('theme image replacement lifecycle', () => { expect(blobs.has('custom-wallpaper')).toBe(true); expect(runtime.deactivate).toHaveBeenCalledWith(manifest.id, { revertThemes: false }); expect(JSON.stringify(next)).not.toContain('blob'); - await service.disable(manifest.id); expect(blobs.has(next.wallpaperAssetId!)).toBe(true); await service.uninstall(manifest.id); expect([...blobs.keys()]).toEqual(['custom-wallpaper']); diff --git a/apps/web/src/lib/services/official-plugins/theme-wallpaper.test.ts b/apps/web/src/lib/services/official-plugins/theme-wallpaper.test.ts index b64a6a52..09736b28 100644 --- a/apps/web/src/lib/services/official-plugins/theme-wallpaper.test.ts +++ b/apps/web/src/lib/services/official-plugins/theme-wallpaper.test.ts @@ -83,7 +83,6 @@ describe('theme wallpaper assets', () => { manifest: { id: 'theme-owner', colorsUrl: '/colors.json', colorsSha256: hash } as never, colorsJson: colors, wallpaperAssetId: 'cached-image', - enabled: true, origin: { kind: 'user' as const }, installedAt: 1 }); diff --git a/tests/browser/pwa-update.spec.ts b/tests/browser/pwa-update.spec.ts index 5ef14b71..f12a54c1 100644 --- a/tests/browser/pwa-update.spec.ts +++ b/tests/browser/pwa-update.spec.ts @@ -125,7 +125,7 @@ test('rejects an unauthorized worker and keeps the current timetable', async ({ ).toBe(false); }); -test('retries preparation, updates every window and preserves disabled plugins offline', async ({ +test('retries preparation, updates every window and preserves installed plugins offline', async ({ page, context, request @@ -145,16 +145,6 @@ test('retries preparation, updates every window and preserves disabled plugins o ) ) .toBe(true); - await page.getByRole('tab', { name: /^已安装/ }).click(); - await page.getByRole('switch').click(); - await expect - .poll( - async () => - (await stored(page)).installation?.records.find( - (record: { manifest: { id: string } }) => record.manifest.id === 'tool-clock' - )?.enabled - ) - .toBe(false); const other = await context.newPage(); await other.goto('/Chronos/'); await expect.poll(() => workerBuild(other)).toBe(oldFeed.host.buildId); @@ -179,10 +169,10 @@ test('retries preparation, updates every window and preserves disabled plugins o const after = await stored(page); expect(after.installation.prepared).toBeUndefined(); expect( - after.installation.records.find( + after.installation.records.some( (record: { manifest: { id: string } }) => record.manifest.id === 'tool-clock' - )?.enabled - ).toBe(false); + ) + ).toBe(true); expect(after.courses).toEqual(before.courses); expect(after.tables).toEqual(before.tables); await context.setOffline(true); From e027123269af8945f9945ec91011e69b21258f43 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:15:08 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20=E9=98=B2=E6=AD=A2=E8=B7=A8?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E8=AF=AF=E7=A1=AE=E8=AE=A4=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .../official-plugins/installed-store.ts | 4 ++- .../official-plugin-service.test.ts | 34 +++++++++++++++++++ .../official-plugin-service.ts | 6 +++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/services/official-plugins/installed-store.ts b/apps/web/src/lib/services/official-plugins/installed-store.ts index 8c21fd28..ff439b9c 100644 --- a/apps/web/src/lib/services/official-plugins/installed-store.ts +++ b/apps/web/src/lib/services/official-plugins/installed-store.ts @@ -221,10 +221,12 @@ export class OfficialPluginInstalledStore { state.removed = [...new Set([...state.removed, id])]; }); } - async acceptHostVersion(id: string, hostVersion: string) { + async acceptHostVersion(id: string, hostVersion: string, expectedRevision: number) { await this.mutate((state) => { const record = state.records.find((record) => record.manifest.id === id); if (!record) throw new Error(`Plugin not installed: ${id}`); + if ((record.revision ?? -1) !== expectedRevision) + throw new Error('Plugin changed during compatibility confirmation; retry'); record.acceptedHostVersion = hostVersion; record.revision = (record.revision ?? 0) + 1; }); diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts b/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts index a9b81314..0d056a67 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-service.test.ts @@ -599,6 +599,40 @@ describe('OfficialPluginService', () => { expect(service.getInstalled('test-plugin')?.acceptedHostVersion).toBe('0.4.1'); }); + it('rejects compatibility confirmation when another window replaces the plugin during activation', async () => { + const other = new OfficialPluginInstalledStore(engine); + await other.load(); + await other.upsert({ + manifest: { + id: 'test-plugin', + version: '0.4.0', + type: 'tool', + bundleFormat: 'esm', + bundleUrl: 'bundle.js', + sha256: await engine.runtime.sha256(SAMPLE_BUNDLE) + } as PluginManifest, + code: SAMPLE_BUNDLE, + manifestUrl: 'https://cdn.example.com/plugin/manifest.json', + origin: { kind: 'user' }, + installedAt: 1 + }); + await service.init(); + const load = engine.loadPlugin.bind(engine); + vi.spyOn(engine, 'loadPlugin').mockImplementationOnce(async (...args) => { + const handle = await load(...args); + await other.upsert({ + ...other.find('test-plugin')!, + manifest: { ...other.find('test-plugin')!.manifest, version: '0.4.2' } + }); + return handle; + }); + await expect(service.confirmHostCompatibility('test-plugin')).rejects.toThrow('Plugin changed'); + await other.load(); + expect(other.find('test-plugin')?.manifest.version).toBe('0.4.2'); + expect(other.find('test-plugin')?.acceptedHostVersion).toBeUndefined(); + expect(engine.isPluginLoaded('test-plugin')).toBe(false); + }); + it('retains incompatible official plugins without executing them when offline', async () => { const hash = await engine.env.runtime.sha256(SAMPLE_BUNDLE); const staleManifest: PluginManifest = { diff --git a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts index 601f90a1..dc4153eb 100644 --- a/apps/web/src/lib/services/official-plugins/official-plugin-service.ts +++ b/apps/web/src/lib/services/official-plugins/official-plugin-service.ts @@ -790,7 +790,11 @@ export class OfficialPluginService implements Disposable { try { this.lifecycle.signal.throwIfAborted(); signal.throwIfAborted(); - await this.installedStore.acceptHostVersion(pluginId, this.hostVersion); + await this.installedStore.acceptHostVersion( + pluginId, + this.hostVersion, + record.revision ?? -1 + ); this.activeVersions.set(pluginId, this.installedStore.find(pluginId)?.revision); this.updateStatuses.set(pluginId, { status: 'ready' }); this.installedStore.notify(); From f51f0968a016cd41fed91a94500b37b655683914 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:15:37 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=8E=A8=20=E5=B0=86=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8D=B8=E8=BD=BD=E6=8C=89=E9=92=AE=E4=B8=8E=E9=A2=84=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=A0=87=E8=AE=B0=E7=A7=BB=E8=87=B3=E5=8F=B3=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .../components/plugins/PluginsScreen.svelte | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/apps/web/src/lib/components/plugins/PluginsScreen.svelte b/apps/web/src/lib/components/plugins/PluginsScreen.svelte index 7604c0b3..37b7db3b 100644 --- a/apps/web/src/lib/components/plugins/PluginsScreen.svelte +++ b/apps/web/src/lib/components/plugins/PluginsScreen.svelte @@ -380,9 +380,11 @@ {@const meta = getPluginCategoryMeta(resolvePluginCatalogCategory(record.manifest))} {@const isBusy = operatingPluginId === record.manifest.id} {@const update = updateStatuses[record.manifest.id]} -
-
-
+
+
+
{name} @@ -404,33 +406,47 @@

{/if}
+ + {#if update && update.status !== 'ready'} +

+ {hostT(`plugins.update.${update.status}`)} +

+ {#if update.status === 'confirmation-required'} + + {:else if update.status !== 'downloading'} + + {/if} + {/if} + {#if record.manifest.configSchema} +
+ +
+ {/if} +
+
{#if officialPlugins.isPreinstalledPlugin(record.manifest.id)} {hostT('plugins.preinstall.label')} - {/if} -
- - {#if update && update.status !== 'ready'} -

- {hostT(`plugins.update.${update.status}`)} -

- {#if update.status === 'confirmation-required'} - - {:else if update.status !== 'downloading'} - - {/if} - {/if} -
- {#if !officialPlugins.isPreinstalledPlugin(record.manifest.id)} + {hostT('plugins.preinstall.label')} + + {:else} - {/if} -
{/each} From 9c25cb5716aedb46474eb1e79c161a51f5fc587a Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:15:53 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=8E=A8=20=E7=BB=9F=E4=B8=80=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AE=89=E8=A3=85=E6=8C=89=E9=92=AE=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=87=E8=AE=B0=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .../web/src/lib/components/plugins/PluginInstallAction.svelte | 4 ++-- apps/web/src/lib/components/plugins/PluginsScreen.svelte | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/components/plugins/PluginInstallAction.svelte b/apps/web/src/lib/components/plugins/PluginInstallAction.svelte index acf006ff..4623dcaa 100644 --- a/apps/web/src/lib/components/plugins/PluginInstallAction.svelte +++ b/apps/web/src/lib/components/plugins/PluginInstallAction.svelte @@ -132,7 +132,7 @@ {:else if installed} {hostT('plugins.badge.installed')} @@ -140,7 +140,7 @@ {:else}