Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/client-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.8",
"@inkcre/core": "workspace:*",
"@inkcre/extension-runtime-client-web": "https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.1.0/inkcre-extension-runtime-client-web-0.1.0.tgz",
"@inkcre/ui-web": "1.4.0",
"@module-federation/runtime": "^0.21.4",
"@supabase/postgrest-js": "^2.84.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ A component that displays extension information and provides controls for toggli
## Props

- `extension` (InstalledExtension, required): the canonical installed row
- `enabled` (boolean, required): whether the current Web Peer UUID is in `enabled[]`
- `enabled` (boolean, required): whether the selected Client's Peer UUID is in `enabled[]`
- `controlsCurrentWebRuntime` (boolean, required): whether the switch owns this browser's runtime
- `setEnabled` (function, required): application-level selected-Client control operation

## Emits

- `changed`: Emitted after current-Peer enablement changes and the list should be refreshed
- `updated`: Emitted with the canonical row after configuration or version changes
- `uninstalled`: Emitted after the canonical row is removed

## Features

- Display canonical Extension Name, exact version, and optional nickname
- Toggle enable/disable status with a switch
- 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
- Auto-formats configuration as JSON for easier editing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import type { InstalledExtension } from '@inkcre/core'
export const extensionCardProps = {
extension: { type: Object as PropType<InstalledExtension>, required: true },
enabled: { type: Boolean, required: true },
controlsCurrentWebRuntime: { type: Boolean, required: true },
setEnabled: {
type: Function as PropType<(enabled: boolean) => Promise<InstalledExtension>>,
required: true,
},
} as const

// --- Emits ---
export const extensionCardEmits = {
changed: () => true,
updated: (_extension: InstalledExtension) => true,
uninstalled: () => true,
} as const
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, nextTick, shallowRef, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { InkButton, InkSwitch, InkDialog, InkInput, InkJsonEditor } from '@inkcre/ui-web'
import { getExtensionHost } from '@/core'
import { getExtensionHost, getExtensionSetupContribution } from '@/core'
import { extensionCardProps, extensionCardEmits } from './extensionCard'

const props = defineProps(extensionCardProps)
Expand All @@ -12,6 +12,9 @@ const { t } = useI18n()
// --- data ---
const configPopupOpen = ref<boolean | Promise<boolean>>(false)
const versionPopupOpen = ref<boolean | Promise<boolean>>(false)
const setupPopupOpen = ref(false)
const setupComponent = shallowRef<Component | null>(null)
const setupContribution = shallowRef(getExtensionSetupContribution(props.extension.name))
const togglePromise = ref<Promise<boolean> | null>(null)
const isUninstalling = ref(false)
const operationError = ref<string | null>(null)
Expand All @@ -28,24 +31,27 @@ watch(
)

const canUninstall = computed(() => props.extension.enabled.length === 0 && !isUninstalling.value)
const closeSetup = async () => {
setupPopupOpen.value = false
setupComponent.value = null
await nextTick()
}

const toggleModel = computed({
get: () => (togglePromise.value ? togglePromise.value : props.enabled),
set: (enabled: boolean) => {
operationError.value = null
togglePromise.value = (async () => {
try {
if (enabled) {
await getExtensionHost().enable(props.extension.name)
} else {
await getExtensionHost().disable(props.extension.name)
}
emit('changed')
if (!enabled && props.controlsCurrentWebRuntime) await closeSetup()
const updated = await props.setEnabled(enabled)
emit('updated', updated)
return enabled
} catch (error) {
operationError.value = error instanceof Error ? error.message : String(error)
return props.enabled
} finally {
setupContribution.value = getExtensionSetupContribution(props.extension.name)
togglePromise.value = null
}
})()
Expand All @@ -56,6 +62,13 @@ const onEditConfigClick = () => {
configPopupOpen.value = true
}

const onSetupClick = () => {
const contribution = setupContribution.value
if (!contribution) return
setupComponent.value = contribution.component
setupPopupOpen.value = true
}

const onChangeVersionClick = () => {
versionModel.value = props.extension.version
versionPopupOpen.value = true
Expand Down Expand Up @@ -123,6 +136,13 @@ const onUninstall = async () => {
</div>

<div class="extension-card__actions">
<InkButton
v-if="setupContribution"
:text="t('extension.setup')"
theme="primary"
size="sm"
@click="onSetupClick"
/>
<InkButton @click="onEditConfigClick" :text="t('extension.editConfig')" size="sm" />
<InkButton
@click="onChangeVersionClick"
Expand All @@ -146,6 +166,17 @@ const onUninstall = async () => {
</p>
<p v-if="operationError" class="extension-card__error">{{ operationError }}</p>

<InkDialog
v-model="setupPopupOpen"
:title="t('extension.setupTitle', { name: extension.nickname ?? extension.name })"
:show-cancel="false"
:show-confirm="false"
:close-on-scrim="false"
@update:model-value="(open) => !open && closeSetup()"
>
<component :is="setupComponent" v-if="setupComponent" @close="closeSetup" />
</InkDialog>

<InkDialog
v-model="configPopupOpen"
:title="t('extension.editConfigTitle')"
Expand Down
132 changes: 93 additions & 39 deletions apps/client-web/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@

import {
configStore,
getMFImplementation,
ExtensionRegistryOriginResolver,
localStorageAdapter,
PostgrestExtensionStatePort,
RegistryExtensionReleaseReader,
setMFImplementation,
registerCoreResolvers,
TextResolver,
AudioResolver,
Expand All @@ -23,11 +20,17 @@ import {
HtmlResolver,
ZipResolver,
PeerManager,
WebPeerRuntime,
JobManager,
WebExtensionHost,
type ExtensionStatePort,
type ExtensionModule,
type ExtensionSetupContribution,
} from '@inkcre/core'
import { createInstance } from '@module-federation/enhanced/runtime'
import { createInstance } from '@module-federation/runtime'
import {
ExtensionManager,
RegistryReleaseReader,
type WebExtensionModule,
} from '@inkcre/extension-runtime-client-web'
import * as InKCreCore from '@inkcre/core'
import * as Zod from 'zod'
import * as Vue from 'vue'
Expand Down Expand Up @@ -78,38 +81,87 @@ export function setupResolvers(): void {
// Configuration
// ============================================================================

let extensionHost: WebExtensionHost | null = null

export function initializeExtensionHost(state: ExtensionStatePort): WebExtensionHost {
extensionHost = new WebExtensionHost({
state,
releases: new RegistryExtensionReleaseReader(
() => configStore.peerConfig.extension_registry_url
),
moduleFederation: getMFImplementation,
currentPeerId: () => configStore.metaConfig.INKCRE_PEER_ID,
hostSdkVersion: corePackageJson.version,
type ClientExtensionModule = WebExtensionModule & ExtensionModule
type ClientExtensionManager = ExtensionManager<ClientExtensionModule>

let extensionHost: ClientExtensionManager | null = null
let extensionHostStartup: Promise<void> | null = null
let moduleFederation: ReturnType<typeof createInstance> | null = null
let webPeerRuntime: WebPeerRuntime | null = null

export function initializeExtensionHost(): ClientExtensionManager {
extensionHostStartup = null
const registryOrigin = new ExtensionRegistryOriginResolver(
() => configStore.peerConfig.extension_registry_url
)
if (!moduleFederation) throw new Error('Module Federation has not been initialized.')
extensionHost = new ExtensionManager<ClientExtensionModule>({
releases: new RegistryReleaseReader({
registryOrigin: () => registryOrigin.resolve(),
hostSdk: { name: '@inkcre/core', version: corePackageJson.version },
}),
moduleFederation,
})
return extensionHost
}

export function getExtensionHost(): WebExtensionHost {
/** Share one initial runtime restore across the app shell and management view. */
export function startExtensionHost(): Promise<void> {
if (extensionHostStartup) return extensionHostStartup
const startup = getExtensionHost().startup(configStore.metaConfig.INKCRE_PEER_ID)
extensionHostStartup = startup.catch((error: unknown) => {
extensionHostStartup = null
throw error
})
return extensionHostStartup
}

export function getExtensionHost(): ClientExtensionManager {
if (!extensionHost) {
throw new Error('Web Extension Host state port has not been initialized.')
}
return extensionHost
}

/** Project the running native module into the Client-owned setup popup contract. */
export function getExtensionSetupContribution(name: string): ExtensionSetupContribution | null {
return getExtensionHost().getModule(name)?.setup ?? null
}

/** Replace the browser-owned lease runtime after a validated Settings cutover. */
export function adoptWebPeerRuntime(runtime: WebPeerRuntime): void {
webPeerRuntime?.stop()
webPeerRuntime = runtime
}

export function stopWebPeerRuntime(): void {
webPeerRuntime?.stop()
webPeerRuntime = null
}

/** Start the lease after Settings has mounted and loaded recovery configuration. */
export async function startConfiguredWebPeerRuntime(): Promise<void> {
if (!configStore.metaConfig.INKCRE_PGREST_URL || !configStore.metaConfig.INKCRE_JWT_SECRET) return
const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID)
try {
await candidate.start()
adoptWebPeerRuntime(candidate)
} catch (error) {
candidate.stop()
throw error
}
}

// ============================================================================
// Module Federation
// ============================================================================

/**
* Initialize Module Federation runtime.
* Creates the MF instance and injects it into core.
* Creates the MF instance consumed by the application-owned Extension manager.
*/
export function initializeModuleFederation(): void {
const mfInstance = createInstance({
moduleFederation = createInstance({
name: 'host',
remotes: [],
shared: {
Expand Down Expand Up @@ -164,21 +216,6 @@ export function initializeModuleFederation(): void {
},
})

// Inject MF implementation into core
const mfImpl = {
registerRemotes: (
remotes: Array<{ name: string; entry: string; type?: 'module' | 'script' }>,
options?: { force?: boolean }
) => {
mfInstance.registerRemotes(remotes, options)
},
loadRemote: async <T>(remoteName: string): Promise<T | null> => {
return mfInstance.loadRemote<T>(remoteName)
},
}

setMFImplementation(mfImpl)

console.log('[Core] Module Federation initialized')
}

Expand All @@ -196,17 +233,34 @@ export function shouldLoadPeerConfigAtBootstrap(pathname: string): boolean {

export async function initializeCore(options: { loadPeerConfig?: boolean } = {}): Promise<void> {
await configStore.initializeMeta(localStorageAdapter)
if (options.loadPeerConfig ?? true) {
await configStore.loadPeerConfig()
await configStore.saveMeta()
const requirePeerConnection = options.loadPeerConfig ?? true
if (
requirePeerConnection &&
configStore.metaConfig.INKCRE_PGREST_URL &&
configStore.metaConfig.INKCRE_JWT_SECRET
) {
const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID)
try {
await candidate.register()
await configStore.loadPeerConfig()
await candidate.start()
adoptWebPeerRuntime(candidate)
} catch (error) {
candidate.stop()
throw error
}
}
PeerManager.setupBuiltinOutbounds()
JobManager.startWorker()
setupResolvers()
initializeModuleFederation()
initializeExtensionHost(new PostgrestExtensionStatePort())
initializeExtensionHost()
if (webPeerRuntime) await webPeerRuntime.start()
console.log('[Core] Initialization complete')
}

export function shutdownCore(): void {
stopWebPeerRuntime()
JobManager.stopWorker()
}
Loading
Loading