Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/onboarding-compatible-hosts.md
Original file line number Diff line number Diff line change
@@ -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 安装。首次连接成功后再启动浏览器任务扫描,重置时先停止任务。
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export const extensionCardProps = {
extension: { type: Object as PropType<InstalledExtension>, 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<InstalledExtension>>,
required: true,
},
setEnabled: {
type: Function as PropType<(enabled: boolean) => Promise<InstalledExtension>>,
required: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -154,7 +151,7 @@ const onUninstall = async () => {
{{ extension.nickname }}
</span>
</div>
<InkSwitch v-model="toggleModel" size="xs" />
<InkSwitch v-model="toggleModel" size="xs" :aria-label="extension.name" />
</div>

<div class="extension-card__actions">
Expand All @@ -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"
/>
<InkButton
@click="onUninstall"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ A component that provides a form interface for installing new extensions.

- Inline form for one canonical `namespace/name` and exact version
- Install button with loading state
- Host preflight requires a published native Web Distribution before state insertion
- The parent supplies installation on the selected Host. The browser requires a Web distribution;
a live Core validates its Python distribution. Installation does not enable either Host.
- Offline installation is disabled; failures retain the input and never select another Host.
- Automatic form reset after successful installation
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import type { PropType } from 'vue'
import type { InstalledExtension, InstallExtensionInput } from '@inkcre/core'

export const installExtensionProps = {
install: {
type: Function as PropType<(coordinate: InstallExtensionInput) => Promise<InstalledExtension>>,
required: true,
},
disabled: { type: Boolean, default: false },
} as const

// --- Emits ---
export const installExtensionEmits = {
install: () => true,
busy: (_value: boolean) => true,
} as const
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -15,20 +15,21 @@ const error = ref<string | null>(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)
}
}
</script>
Expand All @@ -53,7 +54,7 @@ const onSubmit = async () => {
required
/>

<p v-if="error" class="install-extension__error">{{ error }}</p>
<p v-if="error" role="alert" class="install-extension__error">{{ error }}</p>

<div class="footer">
<InkButton
Expand All @@ -62,6 +63,7 @@ const onSubmit = async () => {
size="md"
native-type="submit"
:is-loading="isLoading"
:disabled="disabled"
/>
</div>
</InkForm>
Expand Down
8 changes: 4 additions & 4 deletions apps/client-web/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
webPeerRuntime?.stop()
webPeerRuntime = null
await JobManager.stopWorker()
}

/** Start the lease after Settings has mounted and loaded recovery configuration. */
Expand Down Expand Up @@ -261,7 +263,6 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {})
}
}
PeerManager.setupBuiltinOutbounds()
JobManager.startWorker()
setupResolvers()
initializeModuleFederation()
initializeExtensionHost()
Expand All @@ -270,6 +271,5 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {})
}

export async function shutdownCore(): Promise<void> {
stopWebPeerRuntime()
await JobManager.stopWorker()
await stopWebPeerRuntime()
}
51 changes: 51 additions & 0 deletions apps/client-web/src/extension-peer-control.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<InstalledExtension> {
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)
}
2 changes: 2 additions & 0 deletions apps/client-web/src/locales/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions apps/client-web/src/locales/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@
"setupTitle": "设置 {name}",
"peerSelector": "控制扩展的客户端",
"peerSelectorPlaceholder": "选择客户端",
"installOnSelectedClient": "所选客户端负责安装与版本变更的兼容性校验。安装记录由整个实例共享;请在需要运行扩展的客户端上分别启用。",
"installRequiresLiveClient": "安装或变更版本需要选择在线且支持扩展管理的客户端。",
"peerNotFound": "所选客户端已不存在。",
"currentBrowser": "当前浏览器",
"desiredStateOnly": "此客户端没有在线管理端点;操作只更新其持久启用状态,并在该客户端下次协调状态时生效。",
Expand Down
1 change: 1 addition & 0 deletions apps/client-web/src/views/extensions/extensions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
}

&__header {
flex-basis: 100%;
display: grid;
gap: sys-var(space, sm);
}
Expand Down
78 changes: 60 additions & 18 deletions apps/client-web/src/views/extensions/extensions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -18,6 +27,7 @@ const peersLoading = ref(false)
const extensionsLoading = ref(false)
const error = ref<string | null>(null)
const peerError = ref<string | null>(null)
const installing = ref(false)

const currentPeerFallback = Peer.parse({
id: currentPeerId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -122,24 +150,34 @@ const setEnabledForSelectedPeer = (

<template>
<main class="extensions-view">
<installExtension @install="onInstallExtension" />
<div class="extensions-view__header">
<InkDropdown
v-model="selectedPeerId"
:label="t('extension.peerSelector')"
:placeholder="t('extension.peerSelectorPlaceholder')"
:options="peerOptions"
:disabled="installing"
/>
<p class="extensions-view__notice">{{ t('extension.installOnSelectedClient') }}</p>
<p v-if="!canInstall" class="extensions-view__notice">
{{ t('extension.installRequiresLiveClient') }}
</p>
<p v-if="selectedControlMode === 'desired-state'" class="extensions-view__notice">
{{ t('extension.desiredStateOnly') }}
</p>
<p v-if="peerError" class="extensions-view__error">
{{ t('extension.peerListUnavailable', { error: peerError }) }}
</p>
</div>

<div class="extensions-view__list">
<div class="extensions-view__header">
<InkDropdown
v-model="selectedPeerId"
:label="t('extension.peerSelector')"
:placeholder="t('extension.peerSelectorPlaceholder')"
:options="peerOptions"
/>
<p v-if="selectedControlMode === 'desired-state'" class="extensions-view__notice">
{{ t('extension.desiredStateOnly') }}
</p>
<p v-if="peerError" class="extensions-view__error">
{{ t('extension.peerListUnavailable', { error: peerError }) }}
</p>
</div>
<installExtension
:install="(coordinate) => installForSelectedPeer(coordinate, 'install')"
:disabled="!canInstall || peersLoading"
@busy="installing = $event"
@install="onInstallExtension"
/>

<div class="extensions-view__list">
<div v-if="extensionsLoading || peersLoading" class="flex items-center justify-center">
<InkLoading />
</div>
Expand All @@ -152,6 +190,10 @@ const setEnabledForSelectedPeer = (
:enabled="isEnabledForSelectedPeer(extension)"
:controls-current-web-runtime="selectedControlMode === 'current-runtime'"
:set-enabled="(enabled) => setEnabledForSelectedPeer(extension, enabled)"
:can-change-version="canInstall"
:change-version="
(version) => installForSelectedPeer({ name: extension.name, version }, 'change-version')
"
@updated="updExtension"
@uninstalled="refreshExtensions"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/client-web/src/views/settings/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ const onSave = async () => {

// Reset config
const onReset = async () => {
await stopWebPeerRuntime()
await configStore.resetMeta()
stopWebPeerRuntime()
Object.assign(metaFormConfig, configStore.metaConfig)
Object.assign(peerFormConfig, PeerConfigSchema.parse(configStore.peerConfig))
}
Expand Down
Loading
Loading