From 7b8fe9bdf894c09b276978e5f18509acc99a986d Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 20 Sep 2026 19:05:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20=E6=89=93=E9=80=9A=20Web=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E4=B8=8E=E5=85=BC=E5=AE=B9=20Extension=20?= =?UTF-8?q?=E5=8F=91=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按所选客户端安装和切换版本,保留失败草稿与逐 Peer 启用边界 - 成功连接后启动任务扫描,重置先等待 worker 停止 - 对齐 Twitter、Mail 双 Host 版本并校验公开产物重新验证缓存语义 --- .changeset/onboarding-compatible-hosts.md | 7 ++ .../extension/extensionCard/extensionCard.md | 4 +- .../extension/extensionCard/extensionCard.ts | 5 ++ .../extension/extensionCard/extensionCard.vue | 9 +-- .../installExtension/installExtension.md | 4 +- .../installExtension/installExtension.ts | 12 +++ .../installExtension/installExtension.vue | 14 ++-- apps/client-web/src/core.ts | 8 +- apps/client-web/src/extension-peer-control.ts | 51 ++++++++++++ apps/client-web/src/locales/messages/en.json | 2 + .../src/locales/messages/zh-CN.json | 2 + .../src/views/extensions/extensions.scss | 1 + .../src/views/extensions/extensions.vue | 78 ++++++++++++++----- .../src/views/settings/settings.vue | 2 +- .../client-runtime-and-delegation.md | 6 ++ docs/30-unit-tdd/native-extension-runtime.md | 9 ++- .../native-extension-delivery.md | 15 +++- .../verify-native-extension-distribution.mjs | 15 ++-- 18 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 .changeset/onboarding-compatible-hosts.md diff --git a/.changeset/onboarding-compatible-hosts.md b/.changeset/onboarding-compatible-hosts.md new file mode 100644 index 00000000..22a2ba9e --- /dev/null +++ b/.changeset/onboarding-compatible-hosts.md @@ -0,0 +1,7 @@ +--- +'@inkcre/ext-twitter': minor +'@inkcre/ext-mail': minor +'@inkcre/client-web': patch +--- + +将 Twitter、Mail 浏览器发行分别对齐到支持 Core Host 0.2 的既有 Python 发行 0.4.0、0.3.0。Web 安装和显式换版本交由所选 Host 校验,使 Python-only Extension 可以从 Web 安装。首次连接成功后再启动浏览器任务扫描,重置时先停止任务。 diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.md b/apps/client-web/src/components/extension/extensionCard/extensionCard.md index ec13f249..b33984f9 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.md +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.md @@ -7,6 +7,8 @@ A component that displays extension information and provides controls for toggli - `extension` (InstalledExtension, required): the canonical installed row - `enabled` (boolean, required): whether the selected Client's Peer UUID is in `enabled[]` - `controlsCurrentWebRuntime` (boolean, required): whether the switch owns this browser's runtime +- `canChangeVersion` (boolean, required): whether the selected Host can validate a version change +- `changeVersion` (function, required): application-level version change through the selected Host - `setEnabled` (function, required): application-level selected-Client control operation ## Emits @@ -21,6 +23,6 @@ A component that displays extension information and provides controls for toggli - Mount an Extension-owned setup contribution from this browser's running Web Distribution - Keep setup availability independent of which Client is selected for enablement control - Edit extension configuration via JSON editor in a dialog -- Change the exact shared version only while every Peer is disabled +- Change the exact shared version through the selected Host only while every Peer is disabled - Auto-formats configuration as JSON for easier editing - Prevents uninstall while any Peer remains enabled diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts index 5713e30f..87b26726 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts @@ -6,6 +6,11 @@ export const extensionCardProps = { extension: { type: Object as PropType, required: true }, enabled: { type: Boolean, required: true }, controlsCurrentWebRuntime: { type: Boolean, required: true }, + canChangeVersion: { type: Boolean, required: true }, + changeVersion: { + type: Function as PropType<(version: string) => Promise>, + required: true, + }, setEnabled: { type: Function as PropType<(enabled: boolean) => Promise>, required: true, diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue index 91beb137..e1ef4386 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue @@ -94,10 +94,7 @@ const onConfirmVersion = () => { versionPopupOpen.value = (async () => { try { operationError.value = null - const updatedExtension = await getExtensionHost().changeVersion( - props.extension.name, - versionModel.value - ) + const updatedExtension = await props.changeVersion(versionModel.value.trim()) emit('updated', updatedExtension) return false } catch (error) { @@ -154,7 +151,7 @@ const onUninstall = async () => { {{ extension.nickname }} - +
@@ -170,7 +167,7 @@ const onUninstall = async () => { @click="onChangeVersionClick" :text="t('extension.changeVersion')" size="sm" - :disabled="extension.enabled.length > 0" + :disabled="extension.enabled.length > 0 || !canChangeVersion" /> Promise>, + required: true, + }, + disabled: { type: Boolean, default: false }, +} as const + // --- Emits --- export const installExtensionEmits = { install: () => true, + busy: (_value: boolean) => true, } as const diff --git a/apps/client-web/src/components/extension/installExtension/installExtension.vue b/apps/client-web/src/components/extension/installExtension/installExtension.vue index f6813941..d67b5dff 100644 --- a/apps/client-web/src/components/extension/installExtension/installExtension.vue +++ b/apps/client-web/src/components/extension/installExtension/installExtension.vue @@ -2,9 +2,9 @@ import { ref } from 'vue' import { useI18n } from 'vue-i18n' import { InkForm, InkInput, InkButton } from '@inkcre/ui-web' -import { installExtensionEmits } from './installExtension' -import { getExtensionHost } from '@/core' +import { installExtensionEmits, installExtensionProps } from './installExtension' +const props = defineProps(installExtensionProps) const emit = defineEmits(installExtensionEmits) const { t } = useI18n() @@ -15,20 +15,21 @@ const error = ref(null) // --- methods --- const onSubmit = async () => { - if (isLoading.value) return + if (isLoading.value || props.disabled) return isLoading.value = true + emit('busy', true) try { error.value = null - await getExtensionHost().install(form.value) + await props.install({ name: form.value.name.trim(), version: form.value.version.trim() }) emit('install') // Reset form on success form.value = { name: '', version: '' } } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause) - console.error('Failed to install Registry extension:', cause) error.value = message } finally { isLoading.value = false + emit('busy', false) } } @@ -53,7 +54,7 @@ const onSubmit = async () => { required /> -

{{ error }}

+ diff --git a/apps/client-web/src/core.ts b/apps/client-web/src/core.ts index 7bca4d36..506ed40e 100644 --- a/apps/client-web/src/core.ts +++ b/apps/client-web/src/core.ts @@ -133,11 +133,13 @@ export function getExtensionSetupContribution(name: string): ExtensionSetupContr export function adoptWebPeerRuntime(runtime: WebPeerRuntime): void { webPeerRuntime?.stop() webPeerRuntime = runtime + JobManager.startWorker() } -export function stopWebPeerRuntime(): void { +export async function stopWebPeerRuntime(): Promise { webPeerRuntime?.stop() webPeerRuntime = null + await JobManager.stopWorker() } /** Start the lease after Settings has mounted and loaded recovery configuration. */ @@ -261,7 +263,6 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {}) } } PeerManager.setupBuiltinOutbounds() - JobManager.startWorker() setupResolvers() initializeModuleFederation() initializeExtensionHost() @@ -270,6 +271,5 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {}) } export async function shutdownCore(): Promise { - stopWebPeerRuntime() - await JobManager.stopWorker() + await stopWebPeerRuntime() } diff --git a/apps/client-web/src/extension-peer-control.ts b/apps/client-web/src/extension-peer-control.ts index 9fe50807..2f3badda 100644 --- a/apps/client-web/src/extension-peer-control.ts +++ b/apps/client-web/src/extension-peer-control.ts @@ -1,10 +1,12 @@ import { InstalledExtensionSchema, + InstallExtensionInputSchema, ExtensionModel, Peer, PeerManager, PeerProtocolResponseSchema, type InstalledExtension, + type InstallExtensionInput, } from '@inkcre/core' import type { ExtensionManager } from '@inkcre/extension-runtime-client-web' @@ -67,3 +69,52 @@ export async function setExtensionPeerEnabled(input: { } return InstalledExtensionSchema.parse(response.body) } + +/** Validate with the selected Host; a Python-only Release need not run in the browser. */ +export async function installExtensionForPeer(input: { + coordinate: InstallExtensionInput + peer: Peer + currentPeerId: string + manager: ExtensionManager + operation: 'install' | 'change-version' +}): Promise { + const coordinate = InstallExtensionInputSchema.parse(input.coordinate) + const mode = extensionPeerControlMode(input.peer, input.currentPeerId) + if (mode === 'current-runtime') { + return input.operation === 'install' + ? input.manager.install(coordinate) + : input.manager.changeVersion(coordinate.name, coordinate.version) + } + if (mode !== 'remote-host') { + throw new Error( + 'Installation requires the selected Client to have a live Extension management endpoint.' + ) + } + if (input.operation === 'install') { + const existing = await ExtensionModel.get(coordinate.name) + if (existing && existing.version !== coordinate.version) { + throw new Error( + `${coordinate.name} is already installed at ${existing.version}. Use Change Version after disabling every Peer.` + ) + } + } + const delegated = await PeerManager.delegate( + EXTENSION_MANAGEMENT_CAPABILITY, + { body: { action: 'install', extension: coordinate.name, version: coordinate.version } }, + input.peer.id + ) + const response = PeerProtocolResponseSchema.parse(delegated) + if (response.status !== 200 || response.body === undefined) { + const detail = + response.body !== null && + typeof response.body === 'object' && + 'detail' in response.body && + typeof response.body.detail === 'string' + ? response.body.detail + : 'Check that its Core version supports installation through Extension management.' + throw new Error( + `Installation on the selected Client returned HTTP ${response.status}. ${detail}` + ) + } + return InstalledExtensionSchema.parse(response.body) +} diff --git a/apps/client-web/src/locales/messages/en.json b/apps/client-web/src/locales/messages/en.json index a857e427..326ddaa0 100644 --- a/apps/client-web/src/locales/messages/en.json +++ b/apps/client-web/src/locales/messages/en.json @@ -125,6 +125,8 @@ "setupTitle": "Set up {name}", "peerSelector": "Control Extension on Client", "peerSelectorPlaceholder": "Select a Client", + "installOnSelectedClient": "The selected Client validates installation and version changes. Installation is shared across the instance; enable the Extension separately on each Client that should run it.", + "installRequiresLiveClient": "Select an online Client with Extension management to install or change a version.", "peerNotFound": "The selected Client no longer exists.", "currentBrowser": "This browser", "desiredStateOnly": "This Client has no live management endpoint. Changes update its durable enabled state and take effect when that Client next reconciles it.", diff --git a/apps/client-web/src/locales/messages/zh-CN.json b/apps/client-web/src/locales/messages/zh-CN.json index b84d2342..f816d0df 100644 --- a/apps/client-web/src/locales/messages/zh-CN.json +++ b/apps/client-web/src/locales/messages/zh-CN.json @@ -126,6 +126,8 @@ "setupTitle": "设置 {name}", "peerSelector": "控制扩展的客户端", "peerSelectorPlaceholder": "选择客户端", + "installOnSelectedClient": "所选客户端负责安装与版本变更的兼容性校验。安装记录由整个实例共享;请在需要运行扩展的客户端上分别启用。", + "installRequiresLiveClient": "安装或变更版本需要选择在线且支持扩展管理的客户端。", "peerNotFound": "所选客户端已不存在。", "currentBrowser": "当前浏览器", "desiredStateOnly": "此客户端没有在线管理端点;操作只更新其持久启用状态,并在该客户端下次协调状态时生效。", diff --git a/apps/client-web/src/views/extensions/extensions.scss b/apps/client-web/src/views/extensions/extensions.scss index 8dbcd5b6..b76ce278 100644 --- a/apps/client-web/src/views/extensions/extensions.scss +++ b/apps/client-web/src/views/extensions/extensions.scss @@ -22,6 +22,7 @@ } &__header { + flex-basis: 100%; display: grid; gap: sys-var(space, sm); } diff --git a/apps/client-web/src/views/extensions/extensions.vue b/apps/client-web/src/views/extensions/extensions.vue index efcad471..cd96d00e 100644 --- a/apps/client-web/src/views/extensions/extensions.vue +++ b/apps/client-web/src/views/extensions/extensions.vue @@ -3,9 +3,18 @@ import { computed, onMounted, ref } from 'vue' import extensionCard from '@/components/extension/extensionCard/extensionCard.vue' import installExtension from '@/components/extension/installExtension/installExtension.vue' import { InkDropdown, InkLoading } from '@inkcre/ui-web' -import { configStore, Peer, type InstalledExtension } from '@inkcre/core' +import { + configStore, + Peer, + type InstalledExtension, + type InstallExtensionInput, +} from '@inkcre/core' import { getExtensionHost, startExtensionHost } from '@/core' -import { extensionPeerControlMode, setExtensionPeerEnabled } from '@/extension-peer-control' +import { + extensionPeerControlMode, + setExtensionPeerEnabled, + installExtensionForPeer, +} from '@/extension-peer-control' import { useI18n } from 'vue-i18n' // --- data --- @@ -18,6 +27,7 @@ const peersLoading = ref(false) const extensionsLoading = ref(false) const error = ref(null) const peerError = ref(null) +const installing = ref(false) const currentPeerFallback = Peer.parse({ id: currentPeerId, @@ -58,6 +68,24 @@ const selectedControlMode = computed(() => ) const isEnabledForSelectedPeer = (extension: InstalledExtension) => extension.enabled.includes(selectedPeerId.value) +const canInstall = computed( + () => selectedControlMode.value !== null && selectedControlMode.value !== 'desired-state' +) + +const installForSelectedPeer = ( + coordinate: InstallExtensionInput, + operation: 'install' | 'change-version' +) => { + const peer = selectedPeer.value + if (!peer) throw new Error(t('extension.peerNotFound')) + return installExtensionForPeer({ + coordinate, + peer, + currentPeerId, + manager: getExtensionHost(), + operation, + }) +} const refreshPeers = async () => { peersLoading.value = true @@ -122,24 +150,34 @@ const setEnabledForSelectedPeer = (