diff --git a/.changeset/onboarding-compatible-hosts.md b/.changeset/onboarding-compatible-hosts.md new file mode 100644 index 0000000..22a2ba9 --- /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 ec13f24..b33984f 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 5713e30..87b2672 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 91beb13..e1ef438 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 f681394..d67b5df 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 7bca4d3..506ed40 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 9fe5080..2f3badd 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 a857e42..326ddaa 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 b84d234..f816d0d 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 8dbcd5b..b76ce27 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 efcad47..cd96d00 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 = (