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
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export class DesktopRuntimeHost {
if (!this.inspectExtension)
throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
return this.inspectExtension(id)
}),
}, () => this.#config?.desktop.pluginAuthor ?? ''),
peer.onRequest(contextPanelRpc.presentBrowser, (params) => {
const source = contextPanelSourceSchema.parse(params)
return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness')
Expand Down
6 changes: 6 additions & 0 deletions apps/buddy/electron/main/config/LexoraConfigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import process from 'node:process'
import { parse, stringify } from 'smol-toml'
import { z } from 'zod'
import { browserPreferencesSchema, DEFAULT_BROWSER_PREFERENCES } from '../../../shared/browser/browserPreferences'
import { extensionAuthorSchema } from '../../../shared/extensions/extensionIdentity'
import { DEFAULT_PROXY_SETTINGS, proxySettingsSchema } from '../../../shared/network/proxySettings'
import { DEFAULT_RUNTIME_PREFERENCES, runtimePreferencesSchema } from '../../../shared/runtime/runtimePreferences'
import { keybindingsSchema } from '../../../shared/shortcuts/keybindingSchema'
Expand Down Expand Up @@ -35,6 +36,7 @@ const taskSidebarConfigSchema = z.object({
})

const desktopConfigSchema = z.object({
plugin_author: extensionAuthorSchema.default(''),
background_close_notice_shown: z.boolean().default(false),
chat: z.object({
outline_position: z.enum(DESKTOP_CHAT_OUTLINE_POSITIONS).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition),
Expand Down Expand Up @@ -66,6 +68,7 @@ const desktopConfigSchema = z.object({
theme: z.enum(['system', 'light', 'dark']).default('system'),
}).passthrough().default({
background_close_notice_shown: false,
plugin_author: '',
chat: {
outline_position: DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition,
welcome: DEFAULT_DESKTOP_CHAT_PREFERENCES.welcome,
Expand Down Expand Up @@ -221,6 +224,7 @@ function decodeConfig(value: unknown): LexoraConfig {
proxy: config.proxy,
desktop: {
backgroundCloseNoticeShown: config.desktop.background_close_notice_shown,
pluginAuthor: config.desktop.plugin_author,
chat: {
outlinePosition: config.desktop.chat.outline_position,
welcome: config.desktop.chat.welcome,
Expand Down Expand Up @@ -269,6 +273,7 @@ function encodeConfig(config: LexoraConfig) {
proxy: config.proxy,
desktop: {
background_close_notice_shown: config.desktop.backgroundCloseNoticeShown,
plugin_author: config.desktop.pluginAuthor,
chat: {
outline_position: config.desktop.chat.outlinePosition,
welcome: config.desktop.chat.welcome,
Expand Down Expand Up @@ -312,6 +317,7 @@ function mergeConfig(current: LexoraConfig, patch: LexoraConfigPatch): LexoraCon
desktop: {
...current.desktop,
...patch.desktop,
pluginAuthor: extensionAuthorSchema.parse(patch.desktop?.pluginAuthor ?? current.desktop.pluginAuthor),
chat: {
...current.desktop.chat,
...patch.desktop?.chat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe('lexoraConfigStore', () => {
contextPanelGlobal: false,
keybindings: {},
backgroundCloseNoticeShown: false,
pluginAuthor: '',
chat: { outlinePosition: 'top-right', welcome: 'writing' },
taskSidebarPinnedItems: [],
taskSidebar: {
Expand Down Expand Up @@ -355,3 +356,17 @@ describe('lexoraConfigStore', () => {
})
})
})

it('persists an optional Unicode plugin signature without changing profile or previous data on invalid input', async () => {
const { store, configPath } = await createConfigStore()
expect((await store.read()).desktop.pluginAuthor).toBe('')
await store.update({ desktop: { pluginAuthor: '山雨海 · Équipe 🎨', profile: { userName: '个人名称' } } })
const saved = await readFile(configPath, 'utf8')
const reopened = new LexoraConfigStore({ configPath })
expect((await reopened.read()).desktop.pluginAuthor).toBe('山雨海 · Équipe 🎨')
await expect(store.update({ desktop: { pluginAuthor: '名'.repeat(81) } })).rejects.toThrow()
expect(await readFile(configPath, 'utf8')).toBe(saved)
await store.update({ desktop: { pluginAuthor: '' } })
expect((await reopened.read()).desktop.profile.userName).toBe('个人名称')
expect((await reopened.read()).desktop.pluginAuthor).toBe('')
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RuntimeRequestHandler } from '../../../../shared/runtime/rpcPeer'
import { expect, it, vi } from 'vitest'
import { EXTENSION_CAPABILITIES_RPC, extensionCapabilitiesSchema } from '../../../../shared/extensions/extensionAuthoring'
import { EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, extensionCapabilitiesSchema, extensionIdentitySchema } from '../../../../shared/extensions/extensionAuthoring'
import { EXTENSION_API_VERSION, extensionPlacementSchema } from '../../../../shared/extensions/extensionManifest'
import { registerExtensionAuthoringRpc } from '../registerExtensionAuthoringRpc'

Expand All @@ -13,7 +13,7 @@ it('discovers a compact host index and exact target contracts without loading pl
const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => {
handlers.set(method, handler)
return () => handlers.delete(method)
} }, async () => { throw new Error('Unexpected inspection') })
} }, async () => { throw new Error('Unexpected inspection') }, () => '')
try {
const query = (params: unknown) => Promise.resolve(handlers.get(EXTENSION_CAPABILITIES_RPC)!(params))
const index = extensionCapabilitiesSchema.parse(await query({}))
Expand All @@ -31,3 +31,26 @@ it('discovers a compact host index and exact target contracts without loading pl
}
finally { dispose() }
})

it('prepares platform-independent identities with explicit or default Unicode signatures', async () => {
const handlers = new Map<string, RuntimeRequestHandler>()
let author = '山雨海'
const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => {
handlers.set(method, handler)
return () => handlers.delete(method)
} }, async () => { throw new Error('Unexpected inspection') }, () => author)
try {
const identity = async (input: unknown) => extensionIdentitySchema.parse(await handlers.get(EXTENSION_IDENTITY_RPC)!(input))
const first = await identity({ slug: 'music' })
author = '另一位作者'
const second = await identity({ slug: 'music', author: 'Équipe 🎨' })
expect(first).toMatchObject({ author: '山雨海', engines: { lexora: '>=0.9.0 <1.0.0' } })
expect(first.id).toMatch(/^p[a-f0-9]{32}\.music$/)
expect(second.id).not.toBe(first.id)
expect(second.author).toBe('Équipe 🎨')
expect((await identity({ slug: 'music', author: '' })).author).toBe('')
await expect(identity({ slug: 'Music!' })).rejects.toThrow()
await expect(identity({ slug: 'music', author: '名'.repeat(81) })).rejects.toThrow()
}
finally { dispose() }
})
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer'
import { buildExtensionPackage } from '../../../platform/extensions/buildExtensionPackage'
import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring'
import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionIdentityRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring'
import { createExtensionId } from '../../../shared/extensions/extensionIdentity'
import { EXTENSION_API_VERSION } from '../../../shared/extensions/extensionManifest'
import { queryWorkbenchCapabilities } from '../../../shared/workbench/workbenchUi'
import { compileExtension } from './compileExtension'

export function registerExtensionAuthoringRpc(peer: Pick<RuntimeRpcPeerContract, 'onRequest'>, inspect: (id: string) => Promise<ExtensionInspection>): () => void {
export function registerExtensionAuthoringRpc(peer: Pick<RuntimeRpcPeerContract, 'onRequest'>, inspect: (id: string) => Promise<ExtensionInspection>, author: () => string): () => void {
const lifetime = new AbortController()
let active = 0
const stopIdentity = peer.onRequest(EXTENSION_IDENTITY_RPC, (input) => {
const request = extensionIdentityRequestSchema.parse(input)
return { id: createExtensionId(request.slug), author: request.author ?? author(), engines: { lexora: '>=0.9.0 <1.0.0' } }
})
const stopInspect = peer.onRequest(EXTENSION_INSPECT_RPC, async input => extensionInspectionSchema.parse(await inspect(extensionInspectRequestSchema.parse(input).id)))
const stopCapabilities = peer.onRequest(EXTENSION_CAPABILITIES_RPC, async (input) => {
const query = extensionCapabilitiesRequestSchema.parse(input)
Expand All @@ -29,5 +34,6 @@ export function registerExtensionAuthoringRpc(peer: Pick<RuntimeRpcPeerContract,
stop()
stopInspect()
stopCapabilities()
stopIdentity()
}
}
1 change: 1 addition & 0 deletions apps/buddy/electron/shared/desktopApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export interface LexoraConfig {
notificationsEnabled: boolean
notifyWhenFocused: boolean
profile: DesktopUserProfileConfig
pluginAuthor: string
sidebarCollapsed: boolean
theme: 'system' | 'light' | 'dark'
}
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/shared/desktopApiSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LexoraConfigPatch } from './desktopApi'
import { z } from 'zod'
import { browserPreferencesSchema } from '../../shared/browser/browserPreferences'
import { extensionAuthorSchema } from '../../shared/extensions/extensionIdentity'
import { proxySettingsSchema } from '../../shared/network/proxySettings'
import { runtimePreferencesSchema } from '../../shared/runtime/runtimePreferences'
import { keybindingsSchema } from '../../shared/shortcuts/keybindingSchema'
Expand Down Expand Up @@ -73,6 +74,7 @@ export const lexoraConfigPatchSchema: z.ZodType<LexoraConfigPatch> = z.object({
taskSidebar: taskSidebarPreferencesSchema.optional(),
profile: desktopUserProfilePatchSchema.optional(),
developerToolsEnabled: z.boolean().optional(),
pluginAuthor: extensionAuthorSchema.optional(),
language: z.enum(['zh-CN', 'en-US']).optional(),
launchAtLogin: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ node extensions/tools.mjs pack .output/extensions/my-plugin .output/extensions/m

在「插件 → 安装插件包」中安装生成的包。安装按钮右侧箭头可以加载开发目录;「更多操作 → 安装记录」查看进度、编译错误与取消操作。

`extension.json` 中的 ID 使用 `发布者.插件名`;命令和视图 ID 以插件 ID 加 `.` 开头。清单声明版本、`engines.lexora`、API 版本、贡献项、依赖与权限。`categories` 和 `tags` 用于市场分类与搜索。
`extension.json` 中的 `id` 是稳定的插件身份,创建后在修改、升级和发布时持续复用;可选的 `author` 独立保存作者署名,修改署名不改变 ID。命令和视图 ID 以插件 ID 加 `.` 开头。清单声明版本、`engines.lexora`、API 版本、贡献项、依赖与权限。`categories` 和 `tags` 用于市场分类与搜索。

`icon` 可指定包内 SVG、PNG、JPEG 或 WebP 图标(最多 64 KiB),用于导航、插件卡片与安装弹窗。未提供图标时显示默认线框图标。

Expand Down
1 change: 1 addition & 0 deletions apps/buddy/extensions/sdk/authoring.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { compileExtensionSource } from '../../platform/extensions/compileExtensionSource'
export { extensionIconUrl } from '../../platform/extensions/extensionIcon'
export { createExtensionId, extensionAuthorSchema } from '../../shared/extensions/extensionIdentity'
export { extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest'
export { queryWorkbenchCapabilities, workbenchCapabilityQuerySchema } from '../../shared/workbench/workbenchUi'
7 changes: 1 addition & 6 deletions apps/buddy/platform/extensions/ExtensionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { basename } from 'node:path'
import { z } from 'zod'
import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi'
import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog'
import { extensionCommandNamespace } from '../../shared/extensions/extensionCommands'
import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest'
import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources'
import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule'
Expand Down Expand Up @@ -127,7 +126,7 @@ export class ExtensionService {
blocked = extensionError(error)
}
}
return { manifest: record.current.manifest, iconUrl: await this.store.icon(record.current), revision: record.current.revision, enabled: record.enabled, development: record.development, compatible: extensionCompatible(record.current.manifest, this.store.appVersion), pending: record.pending ? { manifest: record.pending.manifest, revision: record.pending.revision } : null, ...diagnostics, error: blocked ?? diagnostics.error, generation: running?.generation ?? null, state: !record.enabled ? 'disabled' : blocked ? 'blocked' : running ? running.active ? 'active' : 'activating' : diagnostics.error ? 'failed' : 'inactive' }
return { source: record.current.source ?? undefined, manifest: record.current.manifest, iconUrl: await this.store.icon(record.current), revision: record.current.revision, enabled: record.enabled, development: record.development, compatible: extensionCompatible(record.current.manifest, this.store.appVersion), pending: record.pending ? { manifest: record.pending.manifest, revision: record.pending.revision } : null, ...diagnostics, error: blocked ?? diagnostics.error, generation: running?.generation ?? null, state: !record.enabled ? 'disabled' : blocked ? 'blocked' : running ? running.active ? 'active' : 'activating' : diagnostics.error ? 'failed' : 'inactive' }
}))
}

Expand Down Expand Up @@ -634,10 +633,6 @@ export class ExtensionService {
let running = this.#running.get(dependency)
if (!running) {
const pkg = this.store.installed[dependency]!.current
if (pkg.manifest.contributes.commands.some(command => command.slash) && [...this.#running.values()].some(other => other.package.manifest.contributes.commands.some(command => command.slash) && extensionCommandNamespace(other.package.manifest.id) === extensionCommandNamespace(dependency))) {
this.#log(dependency, 'activation.failed', 'EXTENSION_COMMAND_NAMESPACE_CONFLICT')
throw new Error('EXTENSION_COMMAND_NAMESPACE_CONFLICT')
}
const generation = randomUUID()
const abort = new AbortController()
let host: ExtensionHost
Expand Down
20 changes: 13 additions & 7 deletions apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,14 +443,20 @@ it('cancels interaction endpoints when the host stops and keeps broadcasts owned
expect(f.events).toContainEqual(expect.objectContaining({ kind: 'interaction', interactionId: id, title: null }))
})

it('rejects a duplicate plugin namespace and releases it when the original host stops', async () => {
it('runs same-name commands in different plugins without sharing handlers or data', async () => {
const f = await interactionFixture()
const value = manifest({ id: 'other.game', apiVersion: 3, contributes: { commands: [{ id: 'other.game.run', title: 'Run', slash: { name: 'run' } }] } })
const value = manifest({ id: 'other.game', apiVersion: 3, contributes: { commands: [{ id: 'other.game.start', title: 'Start', slash: { name: 'game-start' } }] } })
await f.service.install((await reviewPackage(f.root, f.store, value)).token)
await expect(f.service.executeSlash(value.id, 'other.game.run', '')).rejects.toThrow('EXTENSION_COMMAND_NAMESPACE_CONFLICT')
expect((await f.service.list()).find(item => item.manifest.id === 'tests.game')?.state).toBe('active')
await f.service.executeSlash(value.id, 'other.game.start', 'second')
await f.service.executeSlash('tests.game', 'tests.game.start', 'first')
expect((await f.service.list()).filter(item => item.state === 'active').map(item => item.manifest.id).sort()).toEqual(['other.game', 'tests.game'])
expect(f.hosts[0]!.commands.at(-1)).toMatchObject({ command: 'tests.game.start', arguments: 'first' })
expect(f.hosts[1]!.commands.at(-1)).toMatchObject({ command: 'other.game.start', arguments: 'second' })
await f.store.saveData('tests.game', { selected: 'first' }, 1)
await f.store.saveData('other.game', { selected: 'second' }, 1)
expect((await f.store.data('tests.game')).value).toEqual({ selected: 'first' })
expect((await f.store.data('other.game')).value).toEqual({ selected: 'second' })
await f.service.enable('tests.game', false)
await f.service.restart(value.id)
await f.service.executeSlash(value.id, 'other.game.run', '')
expect((await f.service.list()).find(item => item.manifest.id === value.id)?.state).toBe('active')
await f.service.executeSlash(value.id, 'other.game.start', 'still available')
expect(f.hosts[1]!.commands.at(-1)).toMatchObject({ arguments: 'still available' })
})
2 changes: 1 addition & 1 deletion apps/buddy/platform/extensions/buildExtensionPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function buildExtensionPackage(input: unknown, compile: ExtensionCo
signal.throwIfAborted()
validateExtensionFiles(compiled)
const bytes = zipSync(Object.fromEntries(compiled), { level: 6 })
return { ok: true, id: manifest.id, version: manifest.version, archive: Buffer.from(bytes).toString('base64'), diagnostics }
return { ok: true, id: manifest.id, name: manifest.name, author: manifest.author ?? '', version: manifest.version, archive: Buffer.from(bytes).toString('base64'), diagnostics }
}
catch (error) {
signal.throwIfAborted()
Expand Down
Loading