diff --git a/.changeset/peer-saved-connections.md b/.changeset/peer-saved-connections.md new file mode 100644 index 00000000..ee13ba05 --- /dev/null +++ b/.changeset/peer-saved-connections.md @@ -0,0 +1,7 @@ +--- +'@inkcre/core': patch +'@inkcre/client-web': patch +'@inkcre/client-webext': patch +--- + +支持在 Web 与浏览器扩展保存、切换命名连接,并保留连接身份和上次选择;Web 备份包含完整连接列表。 diff --git a/apps/client-web/src/App.vue b/apps/client-web/src/App.vue index 5d07011f..9512fb6a 100644 --- a/apps/client-web/src/App.vue +++ b/apps/client-web/src/App.vue @@ -31,6 +31,17 @@ const connectionNoticeKey = computed(() => { return 'startup.peerConnecting' }) const reload = () => window.location.reload() +const connectionGeneration = ref(0) +watch( + () => [ + configStore.metaConfig.INKCRE_PGREST_URL, + configStore.metaConfig.INKCRE_JWT_SECRET, + configStore.metaConfig.INKCRE_PEER_ID, + ], + () => { + connectionGeneration.value += 1 + } +) onMounted(async () => { const loadPeerConfig = shouldLoadPeerConfigAtBootstrap(window.location.pathname) @@ -204,9 +215,13 @@ const connectionNoticeVisible = computed( {{ t('common.settings') }} - + - + diff --git a/apps/client-web/src/core.ts b/apps/client-web/src/core.ts index 57e02662..839b9253 100644 --- a/apps/client-web/src/core.ts +++ b/apps/client-web/src/core.ts @@ -17,6 +17,7 @@ import { localStorageAdapter, registerCoreResolvers, TextResolver, + ResolverCache, AudioResolver, EpubResolver, FileResolver, @@ -340,6 +341,7 @@ export async function stopWebPeerRuntime(): Promise { if (webPeerRuntime !== null) return extensionHostStartup = null extensionHostRestoreState.value = 'idle' + ResolverCache.clear() } /** Drain work against the old connection before configuration changes its database authority. */ diff --git a/apps/client-web/src/locales/messages/en.json b/apps/client-web/src/locales/messages/en.json index c1e068ee..369c6ec8 100644 --- a/apps/client-web/src/locales/messages/en.json +++ b/apps/client-web/src/locales/messages/en.json @@ -91,7 +91,11 @@ "peerRuntime": "Local background", "telemetry": "Telemetry", "telemetryRelayDefault": "Leave empty to use deployment telemetry endpoints.", - "backgroundDisabledEffect": "Saving stops background tasks and telemetry; local extensions remain available." + "backgroundDisabledEffect": "Saving stops background tasks and telemetry; local extensions remain available.", + "currentConnection": "Current connection", + "addConnection": "Add connection", + "connectionName": "Connection name", + "saveAndUseConnection": "Save and use connection" }, "source": { "lead": "Manage the places your InKCre collects from; each source is an ongoing channel of new content.", diff --git a/apps/client-web/src/locales/messages/zh-CN.json b/apps/client-web/src/locales/messages/zh-CN.json index f153084a..97f718cd 100644 --- a/apps/client-web/src/locales/messages/zh-CN.json +++ b/apps/client-web/src/locales/messages/zh-CN.json @@ -91,7 +91,11 @@ "peerRuntime": "本机后台", "telemetry": "遥测", "telemetryRelayDefault": "留空使用部署的遥测出口。", - "backgroundDisabledEffect": "保存后停止后台任务和遥测;本地扩展仍可使用。" + "backgroundDisabledEffect": "保存后停止后台任务和遥测;本地扩展仍可使用。", + "currentConnection": "当前连接", + "addConnection": "添加连接", + "connectionName": "连接名称", + "saveAndUseConnection": "保存并使用连接" }, "source": { "lead": "管理采集数据来源;每个数据源对应一个持续进入 InKCre 的内容通道。", diff --git a/apps/client-web/src/views/settings/settings.vue b/apps/client-web/src/views/settings/settings.vue index b973c954..bbdc5cb4 100644 --- a/apps/client-web/src/views/settings/settings.vue +++ b/apps/client-web/src/views/settings/settings.vue @@ -10,7 +10,14 @@ import { InkSwitch, } from '@inkcre/ui-web' import { toast } from '@/components/toast/toast' -import { configStore, MetaConfigSchema, type MetaConfig } from '@inkcre/core' +import { + configStore, + MetaConfigSchema, + ConnectionSettingsSchema, + readConnectionSettings, + type MetaConfig, + type ConnectionSettings, +} from '@inkcre/core' import { z } from 'zod' import { setLocale, SUPPORT_LOCALES, LOCALE_NAMES, type SupportLocale } from '@/locales' import i18n from '@/locales' @@ -28,6 +35,42 @@ const { t } = useI18n() const telemetryLabelId = useId() const metaFormConfig = reactive({ ...configStore.metaConfig }) +const editingId = ref(configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID()) +const connectionName = ref( + configStore.connectionSettings.connections.find((item) => item.id === editingId.value)?.name ?? '' +) +const addingConnection = ref(false) +const connectionOptions = computed(() => + configStore.connectionSettings.connections.map((item) => ({ value: item.id, label: item.name })) +) + +function newConnection() { + editingId.value = crypto.randomUUID() + connectionName.value = '' + addingConnection.value = true + Object.assign(metaFormConfig, MetaConfigSchema.parse({})) + feedback.value = null +} + +async function switchConnection(id: string | number) { + const saved = configStore.connectionSettings.connections.find((item) => item.id === id) + if (!saved || formBusy.value) return + editingId.value = saved.id + connectionName.value = saved.name + Object.assign(metaFormConfig, saved.config) + addingConnection.value = false + const savedSuccessfully = await onSave() + if (!savedSuccessfully) restoreCurrentConnection() +} + +function restoreCurrentConnection() { + Object.assign(metaFormConfig, configStore.metaConfig) + editingId.value = configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID() + connectionName.value = + configStore.connectionSettings.connections.find((item) => item.id === editingId.value)?.name ?? + '' + addingConnection.value = false +} const pendingAction = ref<'save' | 'import' | 'reset' | null>(null) const feedback = ref<{ action: 'save' | 'import' | 'reset' @@ -70,17 +113,30 @@ const leaseRenewIntervalError = computed(() => { ? t('settings.peerLeaseRenewIntervalInvalid') : '' }) -const SettingsExportSchema = z.object({ - version: z.literal(2), - metaConfig: MetaConfigSchema, - locale: z.enum(SUPPORT_LOCALES), -}) +const SettingsExportSchema = z.union([ + z.object({ + version: z.literal(2), + metaConfig: MetaConfigSchema, + locale: z.enum(SUPPORT_LOCALES), + }), + z.object({ + version: z.literal(3), + metaConfig: MetaConfigSchema, + locale: z.enum(SUPPORT_LOCALES), + connections: ConnectionSettingsSchema, + }), +]) // Synchronize metaFormConfig with configStore.metaConfig watch( () => configStore.metaConfig, (newMetaConfig) => { Object.assign(metaFormConfig, newMetaConfig) + editingId.value = configStore.connectionSettings.activeConnectionId ?? crypto.randomUUID() + connectionName.value = + configStore.connectionSettings.connections.find((item) => item.id === editingId.value) + ?.name ?? '' + addingConnection.value = false }, { deep: true } ) @@ -108,18 +164,24 @@ const onSave = async () => { try { const validatedMeta = MetaConfigSchema.parse(metaFormConfig) await prepareWebPeerConfigurationChange(validatedMeta) - const runtime = await configStore.connectAndSave(validatedMeta, WEB_PEER_IDENTITY) + const name = connectionName.value.trim() || new URL(validatedMeta.INKCRE_PGREST_URL).host + const runtime = await configStore.connectAndSave(validatedMeta, WEB_PEER_IDENTITY, { + id: editingId.value, + name, + }) await adoptWebPeerRuntime(runtime) void refreshCurrentWebPeer() Object.assign(metaFormConfig, configStore.metaConfig) feedback.value = { action: 'save', error: false, message: t('settings.saveSuccess') } toast.success(t('settings.saveSuccess')) + return true } catch (error) { if (webPeerConnectionState.value === 'idle' && hasConnectedConfig.value) webPeerConnectionState.value = 'failed' console.error('Failed to save config:', error) feedback.value = { action: 'save', error: true, message: t('common.saveFailed') } toast.error(t('common.saveFailed')) + return false } finally { pendingAction.value = null } @@ -142,7 +204,8 @@ const onReset = async () => { const onExport = () => { const portableConfig = SettingsExportSchema.parse({ - version: 2, + version: 3, + connections: configStore.connectionSettings, metaConfig: configStore.metaConfig, locale: currentLocale.value, }) @@ -173,8 +236,21 @@ const onFileSelected = async (event: Event) => { feedback.value = null try { const imported = SettingsExportSchema.parse(JSON.parse(await file.text())) - await prepareWebPeerConfigurationChange(imported.metaConfig) - const runtime = await configStore.connectAndSave(imported.metaConfig, WEB_PEER_IDENTITY) + const connections: ConnectionSettings = + imported.version === 3 + ? imported.connections + : readConnectionSettings(imported.metaConfig, imported.metaConfig) + const selected = connections.connections.find( + (item) => item.id === connections.activeConnectionId + ) + if (!selected) throw new Error('The backup has no selected connection') + await prepareWebPeerConfigurationChange(selected.config) + const runtime = await configStore.connectAndSave( + selected.config, + WEB_PEER_IDENTITY, + selected, + connections + ) await adoptWebPeerRuntime(runtime) void refreshCurrentWebPeer() await setLocale(imported.locale) @@ -203,6 +279,32 @@ const onFileSelected = async (event: Event) => { >
{{ t('settings.deploymentConnection') }} + +
+ + +
+ {
import logo from '@/assets/inkcre.svg' import { type LLMProviderConfig, type ProviderType } from '@inkcre/core' +import { defaultModel, explainInstruction, llmProviders, stopwords } from '@/logic/storage' import { - defaultModel, - explainInstruction, - inkcreApi, - llmProviders, - stopwords, -} from '@/logic/storage' + apiConnections, + apiConnectionsReady, + apiConnectionIssue, + saveApiConnection, + selectApiConnection, +} from '@/logic/api-connections' import 'uno.css' +const connectionId = ref(crypto.randomUUID()) +const connectionName = ref('') +const connectionUrl = ref('') +const connectionBusy = ref(true) +const connectionFeedback = ref('') + +function showActiveConnection() { + const current = apiConnections.value.connections.find( + (item) => item.id === apiConnections.value.activeId + ) + connectionId.value = current?.id ?? crypto.randomUUID() + connectionName.value = current?.name ?? '' + connectionUrl.value = current?.url ?? '' +} +onMounted(async () => { + await apiConnectionsReady + showActiveConnection() + connectionBusy.value = false +}) +watch(apiConnections, showActiveConnection) +function addConnection() { + connectionId.value = crypto.randomUUID() + connectionName.value = '' + connectionUrl.value = '' + connectionFeedback.value = '' +} +async function applyConnection(id?: string) { + if (connectionBusy.value) return + connectionBusy.value = true + connectionFeedback.value = '' + try { + if (id) await selectApiConnection(id) + else await saveApiConnection(connectionId.value, connectionName.value, connectionUrl.value) + showActiveConnection() + connectionFeedback.value = '已切换连接。' + } catch { + connectionFeedback.value = '无法保存连接,请检查名称和 HTTP(S) 地址。' + } finally { + connectionBusy.value = false + } +} + // Computed property to handle stopwords array/string conversion const stopwordsText = computed({ get: () => (Array.isArray(stopwords.value) ? stopwords.value.join(', ') : ''), @@ -131,16 +174,45 @@ onMounted(() => {

Runtime configuration

-
- +
+ 连接 + + + + + + -
+ +

{{ apiConnectionIssue }}

+

{{ connectionFeedback }}

+
diff --git a/apps/client-webext/logic/api-connections.ts b/apps/client-webext/logic/api-connections.ts new file mode 100644 index 00000000..d4e9f6a9 --- /dev/null +++ b/apps/client-webext/logic/api-connections.ts @@ -0,0 +1,93 @@ +import { computed, ref } from 'vue' +import { storage } from '@wxt-dev/storage' +import { z } from 'zod' + +const ConnectionSchema = z.object({ + id: z.uuid(), + name: z.string().trim().min(1), + url: z.url().refine((value) => { + const url = new URL(value) + return ( + ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash + ) + }, '请输入不含凭据、查询参数或片段的 HTTP(S) 地址。'), +}) +const SettingsSchema = z + .object({ + connections: z.array(ConnectionSchema), + activeId: z.uuid().nullable(), + }) + .superRefine((value, context) => { + const ids = new Set(value.connections.map((item) => item.id)) + if ( + ids.size !== value.connections.length || + (value.activeId !== null && !ids.has(value.activeId)) + ) { + context.addIssue({ code: 'custom', message: '保存的连接列表无效。' }) + } + }) +type Settings = z.infer +const key = 'local:inkcreApiConnections' as const +export const apiConnections = ref({ connections: [], activeId: null }) +export const apiConnectionIssue = ref('') +export const inkcreApi = computed( + () => + apiConnections.value.connections.find((item) => item.id === apiConnections.value.activeId) + ?.url ?? '' +) + +export const apiConnectionsReady = (async () => { + try { + const stored = await storage.getItem(key) + if (stored !== null) { + apiConnections.value = SettingsSchema.parse( + typeof stored === 'string' ? JSON.parse(stored) : stored + ) + return + } + const previous = await storage.getItem('local:inkcreApi') + if (!previous) return + const id = crypto.randomUUID() + await saveApiConnections({ + activeId: id, + connections: [{ id, name: new URL(previous).host, url: previous }], + }) + } catch { + apiConnectionIssue.value = '无法读取保存的连接。' + } +})() + +storage.watch(key, (stored) => { + try { + apiConnections.value = SettingsSchema.parse( + typeof stored === 'string' ? JSON.parse(stored) : stored + ) + apiConnectionIssue.value = '' + } catch { + apiConnectionIssue.value = '保存的连接列表无效。' + } +}) + +async function saveApiConnections(candidate: Settings) { + const settings = SettingsSchema.parse(candidate) + await storage.setItem(key, JSON.stringify(settings)) + apiConnections.value = settings + apiConnectionIssue.value = '' +} + +export async function saveApiConnection(id: string, name: string, url: string) { + await apiConnectionsReady + const connection = ConnectionSchema.parse({ id, name: name.trim() || new URL(url).host, url }) + const connections = apiConnections.value.connections.filter((item) => item.id !== id) + connections.push(connection) + await saveApiConnections({ connections, activeId: id }) +} + +export async function selectApiConnection(id: string) { + await apiConnectionsReady + await saveApiConnections({ ...apiConnections.value, activeId: id }) +} diff --git a/apps/client-webext/logic/info-base/block.ts b/apps/client-webext/logic/info-base/block.ts index c9c6e811..32ba06b6 100644 --- a/apps/client-webext/logic/info-base/block.ts +++ b/apps/client-webext/logic/info-base/block.ts @@ -5,7 +5,7 @@ */ import { Block as CoreBlock } from '@inkcre/core' -import { inkcreApi } from '~/logic/storage' +import { inkcreApi, inkcreApiReady } from '~/logic/storage' /** * Extended Block class with vector search capability @@ -28,6 +28,7 @@ export class Block extends CoreBlock { distanceThreshold?: number num?: number }): Promise { + await inkcreApiReady const url = new URL('/blocks/query/by_embedding', inkcreApi.value) if (params.query !== undefined) { url.searchParams.set('query', params.query) diff --git a/apps/client-webext/logic/info-base/root.ts b/apps/client-webext/logic/info-base/root.ts index 0344a1fb..b18062fa 100644 --- a/apps/client-webext/logic/info-base/root.ts +++ b/apps/client-webext/logic/info-base/root.ts @@ -1,6 +1,6 @@ // (Information) Base Root module index -import { inkcreApi } from '~/logic/storage' +import { inkcreApi, inkcreApiReady } from '~/logic/storage' import type { BlockForm } from './block' export interface RelationInput { @@ -22,7 +22,8 @@ export class StarGraphForm { public in_relations: ArcForm[] = [] ) {} - create() { + async create() { + await inkcreApiReady return fetch(new URL('/graph', inkcreApi.value), { method: 'PUT', headers: { @@ -51,6 +52,7 @@ export class Root { retrieve_mode?: string context_blocks?: string }): Promise { + await inkcreApiReady const url = new URL('/sink/rag', inkcreApi.value) url.searchParams.set('query', encodeURIComponent(params.query)) if (params.retrieve_mode) { diff --git a/apps/client-webext/logic/storage.ts b/apps/client-webext/logic/storage.ts index 35206b40..d452256d 100644 --- a/apps/client-webext/logic/storage.ts +++ b/apps/client-webext/logic/storage.ts @@ -37,14 +37,7 @@ export const { data: stopwords, dataReady: stopwordsReady } = useWebExtensionSto DEFAULT_STOPWORDS ) -/** - * InKCre API base URL (extension-specific, not in core). - * Used for info-base API calls. - */ -export const { data: inkcreApi, dataReady: inkcreApiReady } = useWebExtensionStorage( - 'inkcreApi', - '' -) +export { inkcreApi, apiConnectionsReady as inkcreApiReady } from './api-connections' /** * LLM provider credentials and model lists. diff --git a/docs/30-unit-tdd/client-runtime-and-delegation.md b/docs/30-unit-tdd/client-runtime-and-delegation.md index aa8a6673..9a6d6b86 100644 --- a/docs/30-unit-tdd/client-runtime-and-delegation.md +++ b/docs/30-unit-tdd/client-runtime-and-delegation.md @@ -74,6 +74,12 @@ origin: - current technical Peer UUID; - user-owned JWT signing secret. +每个 Web origin 在本地保存命名连接列表和当前选择。一个条目包含 PostgREST 地址、JWT 签名密钥、稳定的 Peer UUID 和该连接的运行设置。添加连接生成新的身份;切回已保存连接复用原身份。设置中的“保存并使用连接”验证目标后才提交列表与当前选择;连接失败或存储失败不改变已保存的选择。切换前收束旧 Job worker、扩展 Host 和遥测,并清除 Resolver 缓存及连接相关的搜索、侧栏状态。 + +旧单连接会成为列表首项,保留凭据与身份。存储中的扁平 MetaConfig 字段是活动项的兼容投影;重新加载以列表的活动项为准。导出格式 v3 包含全部连接、当前选择和语言,包括连接凭据;导入接受旧 v2 单连接和 v3,并替换本地列表。各浏览器 origin 独立保存,不跨设备同步。 + +浏览器扩展的旧 API 入口独立保存命名 API 地址及当前选择,沿用现有请求协议;其他打开的扩展页面通过 extension storage 接收选择变化。原单一 `inkcreApi` 地址保留为首项。它不把 API 地址冒充 Web 的 PostgREST/JWT 连接,亦不迁移旧协议。 + The application shell mounts immediately with its header and menu. A configured browser reads its complete canonical Peer row once before business views mount; a new identity must first be inserted. Business pages then remain available while runtime-owned version/schema registration and diff --git a/packages/core/src/config/connections.ts b/packages/core/src/config/connections.ts new file mode 100644 index 00000000..5bae865d --- /dev/null +++ b/packages/core/src/config/connections.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import { MetaConfigSchema, type MetaConfig } from './schema' + +export const SavedConnectionSchema = z.object({ + id: z.uuid(), + name: z.string().trim().min(1), + config: MetaConfigSchema, +}) +export type SavedConnection = z.infer + +export const ConnectionSettingsSchema = z + .object({ + connections: z.array(SavedConnectionSchema), + activeConnectionId: z.uuid().nullable(), + }) + .superRefine((settings, context) => { + const ids = new Set(settings.connections.map((connection) => connection.id)) + if ( + ids.size !== settings.connections.length || + (settings.activeConnectionId !== null && !ids.has(settings.activeConnectionId)) + ) { + context.addIssue({ code: 'custom', message: 'Invalid connection selection' }) + } + }) +export type ConnectionSettings = z.infer + +/** Import the previous single-connection format without changing its Peer identity. */ +export function readConnectionSettings(input: unknown, current: MetaConfig): ConnectionSettings { + if (typeof input === 'object' && input !== null && 'connections' in input) { + return ConnectionSettingsSchema.parse(input) + } + if (!current.INKCRE_PGREST_URL || !current.INKCRE_JWT_SECRET) { + return { connections: [], activeConnectionId: null } + } + const id = crypto.randomUUID() + return { + activeConnectionId: id, + connections: [{ id, name: new URL(current.INKCRE_PGREST_URL).host, config: current }], + } +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index ba75ad6a..7b0c3f4b 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -26,3 +26,5 @@ export { useConfigStore, configStore } from './store' export * from './schema' export * from './types' export * from './adapters' + +export * from './connections' diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts index c3632db0..7ff1806b 100644 --- a/packages/core/src/config/store.ts +++ b/packages/core/src/config/store.ts @@ -3,7 +3,11 @@ import { ref, shallowRef } from 'vue' import { store } from '../store' import type { ConfigAdapterWithWrite } from './types' import { MetaConfigSchema, PeerConfigSchema, type MetaConfig, type PeerConfig } from './schema' -import { loadConfig as zodLoadConfig } from 'zod-config' +import { + ConnectionSettingsSchema, + readConnectionSettings, + type ConnectionSettings, +} from './connections' import type { WebPeerIdentity } from '../peer/runtime' // Lazy import Peer to avoid circular imports @@ -25,6 +29,7 @@ export const useConfigStore = defineStore('inkcre-config', () => { const metaAdapter = shallowRef(unconfiguredAdapter) const metaConfig = ref(MetaConfigSchema.parse({})) const peerConfig = ref(PeerConfigSchema.parse({})) + const connectionSettings = ref({ connections: [], activeConnectionId: null }) const isLoading = ref(false) const error = ref(null) @@ -34,14 +39,17 @@ export const useConfigStore = defineStore('inkcre-config', () => { error.value = null try { - metaConfig.value = await zodLoadConfig({ - schema: MetaConfigSchema, - adapters: [adapter], - }) + const stored = await adapter.read() + const current = MetaConfigSchema.parse(stored) + const settings = readConnectionSettings(stored, current) + connectionSettings.value = settings + metaConfig.value = + settings.connections.find((item) => item.id === settings.activeConnectionId)?.config ?? + current } catch (err) { error.value = err as Error - metaConfig.value = MetaConfigSchema.parse({}) console.error('[Config] Failed to initialize meta config.') + throw err } finally { isLoading.value = false } @@ -66,7 +74,14 @@ export const useConfigStore = defineStore('inkcre-config', () => { async function saveMeta(): Promise { error.value = null try { - await metaAdapter.value.write(metaConfig.value) + const settings = ConnectionSettingsSchema.parse(connectionSettings.value) + settings.connections = settings.connections.map((item) => + item.id === settings.activeConnectionId + ? { ...item, config: MetaConfigSchema.parse(metaConfig.value) } + : item + ) + await metaAdapter.value.write({ ...metaConfig.value, ...settings }) + connectionSettings.value = settings } catch (err) { error.value = err as Error console.error('[Config] Failed to save meta config:', err) @@ -74,16 +89,35 @@ export const useConfigStore = defineStore('inkcre-config', () => { } } - async function connectAndSave(metaCandidate: MetaConfig, identity: WebPeerIdentity) { + async function connectAndSave( + metaCandidate: MetaConfig, + identity: WebPeerIdentity, + selection?: { id: string; name: string }, + imported?: ConnectionSettings + ) { error.value = null const nextMeta = MetaConfigSchema.parse(metaCandidate) + const settings = ConnectionSettingsSchema.parse(imported ?? connectionSettings.value) + const existing = settings.connections.find((item) => item.id === settings.activeConnectionId) + const selected = selection ?? + existing ?? { + id: crypto.randomUUID(), + name: new URL(nextMeta.INKCRE_PGREST_URL).host, + } + const entry = { id: selected.id, name: selected.name, config: nextMeta } + const index = settings.connections.findIndex((item) => item.id === selected.id) + if (index < 0) settings.connections.push(entry) + else settings.connections[index] = entry + settings.activeConnectionId = selected.id + const nextSettings = ConnectionSettingsSchema.parse(settings) let candidateRuntime: InstanceType>> | null = null try { const WebPeerRuntime = await lazyWebPeerRuntime() const connection = await WebPeerRuntime.connect(nextMeta, identity) candidateRuntime = connection.runtime - await metaAdapter.value.write(nextMeta) + await metaAdapter.value.write({ ...nextMeta, ...nextSettings }) + connectionSettings.value = nextSettings metaConfig.value = nextMeta peerConfig.value = PeerConfigSchema.parse(connection.peer.config) return candidateRuntime @@ -99,7 +133,9 @@ export const useConfigStore = defineStore('inkcre-config', () => { const resetPeerConfig = PeerConfigSchema.parse({}) error.value = null try { - await metaAdapter.value.write(resetMetaConfig) + const settings: ConnectionSettings = { connections: [], activeConnectionId: null } + await metaAdapter.value.write({ ...resetMetaConfig, ...settings }) + connectionSettings.value = settings metaConfig.value = resetMetaConfig peerConfig.value = resetPeerConfig } catch (err) { @@ -110,6 +146,7 @@ export const useConfigStore = defineStore('inkcre-config', () => { return { metaConfig, + connectionSettings, peerConfig, metaAdapter, isLoading, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbc01c4e..abb6d6b1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -223,6 +223,10 @@ export { configStore, PeerConfigSchema, MetaConfigSchema, + ConnectionSettingsSchema, + readConnectionSettings, + type ConnectionSettings, + type SavedConnection, AIConfigSchema, LLMProviderConfigSchema, localStorageAdapter, diff --git a/scripts/tests/saved-connections.test.mjs b/scripts/tests/saved-connections.test.mjs new file mode 100644 index 00000000..7f001e19 --- /dev/null +++ b/scripts/tests/saved-connections.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + configStore, + MetaConfigSchema, + WebPeerRuntime, + readConnectionSettings, +} from '../../packages/core/dist/index.js' + +void test('saved connections preserve identity, switch atomically, and survive restart/import', async () => { + const first = MetaConfigSchema.parse({ + INKCRE_PGREST_URL: 'https://one.example', + INKCRE_JWT_SECRET: 'fixture-one', + }) + let persisted = structuredClone(first) + let rejectWrite = false + const adapter = { + name: 'memory', + read: async () => persisted, + write: async (value) => { + if (rejectWrite) throw new Error('disk full') + persisted = structuredClone(value) + }, + } + const connect = WebPeerRuntime.connect.bind(WebPeerRuntime) + let stopped = 0 + let rejectConnection = false + WebPeerRuntime.connect = async () => { + if (rejectConnection) throw new Error('unreachable') + return { + runtime: { + stop() { + stopped += 1 + }, + }, + peer: { config: {} }, + } + } + try { + await configStore.initializeMeta(adapter) + const firstId = configStore.connectionSettings.activeConnectionId + assert.equal(configStore.connectionSettings.connections.length, 1) + assert.equal(configStore.metaConfig.INKCRE_PEER_ID, first.INKCRE_PEER_ID) + await configStore.saveMeta() + const second = MetaConfigSchema.parse({ + INKCRE_PGREST_URL: 'https://two.example', + INKCRE_JWT_SECRET: 'fixture-two', + }) + const secondId = crypto.randomUUID() + await configStore.connectAndSave(second, {}, { id: secondId, name: '工作' }) + assert.equal(persisted.connections.length, 2) + await configStore.connectAndSave(first, {}, { id: firstId, name: '个人' }) + await configStore.initializeMeta(adapter) + assert.equal(configStore.connectionSettings.activeConnectionId, firstId) + assert.equal(configStore.metaConfig.INKCRE_PEER_ID, first.INKCRE_PEER_ID) + assert.equal( + configStore.connectionSettings.connections[1].config.INKCRE_PEER_ID, + second.INKCRE_PEER_ID + ) + const snapshot = JSON.stringify(persisted) + rejectConnection = true + await assert.rejects(configStore.connectAndSave(second, {}, { id: secondId, name: '工作' })) + assert.equal(JSON.stringify(persisted), snapshot) + assert.equal(configStore.connectionSettings.activeConnectionId, firstId) + rejectConnection = false + rejectWrite = true + await assert.rejects(configStore.connectAndSave(second, {}, { id: secondId, name: '工作' })) + assert.equal(stopped, 1) + assert.equal(configStore.connectionSettings.activeConnectionId, firstId) + rejectWrite = false + const exported = JSON.parse(JSON.stringify(configStore.connectionSettings)) + await configStore.resetMeta() + await configStore.connectAndSave(first, {}, exported.connections[0], exported) + assert.deepEqual(JSON.parse(JSON.stringify(configStore.connectionSettings)), exported) + assert.equal( + readConnectionSettings(first, first).connections[0].config.INKCRE_PEER_ID, + first.INKCRE_PEER_ID + ) + persisted = { + ...first, + connections: exported.connections, + activeConnectionId: crypto.randomUUID(), + } + await assert.rejects(configStore.initializeMeta(adapter)) + assert.equal(persisted.connections.length, 2) + } finally { + WebPeerRuntime.connect = connect + } +}) diff --git a/tests/e2e/browser-extension.spec.ts b/tests/e2e/browser-extension.spec.ts index 30e9db30..ccc3371a 100644 --- a/tests/e2e/browser-extension.spec.ts +++ b/tests/e2e/browser-extension.spec.ts @@ -41,3 +41,34 @@ test('built Chromium extension loads its real popup', async ({ context, page, ex await expect(page.getByText('Popup', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: 'Open Options' })).toBeVisible() }) + +test('saved API connections survive reload and switch across open option pages', async ({ + context, + page, + extensionId, +}) => { + const [worker] = context.serviceWorkers() + await worker.evaluate(async () => { + const chrome = (globalThis as any).chrome + await chrome.storage.local.set({ inkcreApi: 'https://first.example' }) + }) + await page.goto(`chrome-extension://${extensionId}/options.html`) + await expect(page.getByLabel('API 地址')).toHaveValue('https://first.example') + await page.getByRole('button', { name: '添加连接', exact: true }).click() + await page.getByLabel('连接名称', { exact: true }).fill('工作') + await page.getByLabel('API 地址').fill('https://second.example') + await page.getByRole('button', { name: '保存并使用连接' }).click() + await expect(page.getByRole('status')).toHaveText('已切换连接。') + await page.reload() + await expect(page.getByLabel('API 地址')).toHaveValue('https://second.example') + const other = await context.newPage() + await other.goto(`chrome-extension://${extensionId}/options.html`) + await expect(other.getByLabel('API 地址')).toHaveValue('https://second.example') + await page.getByLabel('当前连接').selectOption({ label: 'first.example' }) + await expect(page.getByLabel('API 地址')).toHaveValue('https://first.example') + await expect(other.getByLabel('API 地址')).toHaveValue('https://first.example') + await other.reload() + await expect(other.getByLabel('API 地址')).toHaveValue('https://first.example') + await expect(other.getByLabel('当前连接').locator('option')).toHaveCount(2) + await other.close() +}) diff --git a/tests/e2e/github-native.ts b/tests/e2e/github-native.ts index 88f76fe2..e0e1c9b9 100644 --- a/tests/e2e/github-native.ts +++ b/tests/e2e/github-native.ts @@ -361,7 +361,7 @@ export function registerGitHubNativeTests(): void { await page .getByLabel('Lease renewal and job check interval (seconds; 0 disables)') .fill(String(seconds)) - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect( page.getByText('Configuration saved successfully', { exact: true }).first() ).toBeVisible() diff --git a/tests/e2e/peer-database.spec.ts b/tests/e2e/peer-database.spec.ts index b82ae771..d207dfe6 100644 --- a/tests/e2e/peer-database.spec.ts +++ b/tests/e2e/peer-database.spec.ts @@ -421,7 +421,7 @@ test('switching connections cannot renew a pending old registration against eith await openSettings(page) await page.getByLabel('PostgREST URL').fill(alternateUrl) await page.getByLabel('JWT signing secret').fill(alternateSecret) - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect.poll(() => newLeaseRequests).toBe(1) release() await expect.poll(() => registrationFinished).toBe(true) @@ -550,14 +550,14 @@ test('zero participation persists, stays idle, and a custom interval resumes lea page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)') ).toHaveValue('0') await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('') - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() expect(leaseTtls).toEqual([]) const intervalInput = page.getByLabel( 'Lease renewal and job check interval (seconds; 0 disables)' ) await intervalInput.fill('2') await expect(intervalInput).toHaveValue('2') - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect(intervalInput).toHaveValue('2') await expect.poll(() => scans).toBeGreaterThan(0) expect(leaseTtls.length).toBeGreaterThan(0) @@ -574,10 +574,12 @@ test('zero participation persists, stays idle, and a custom interval resumes lea const exported = JSON.parse(readFileSync(path!, 'utf8')) expect(exported.metaConfig.peer_lease_renew_interval_seconds).toBe(2) await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0') - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect(page.getByRole('status').first()).toBeVisible() // The import handler bails while the save still owns the form; wait for it to finish. - await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled() + await expect( + page.getByRole('button', { name: 'Save and use connection', exact: true }) + ).toBeEnabled() const stoppedScans = scans, stoppedLeases = leaseTtls.length await page.clock.fastForward(35_000) @@ -624,7 +626,7 @@ test('disabling while registration is pending prevents its later lease or worker await expect(page.getByRole('main')).toBeVisible() await openSettings(page) await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0') - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect(page.getByRole('status').first()).toBeVisible() release() await expect.poll(() => firstPatchFinished).toBe(true) @@ -744,8 +746,10 @@ test('disabling participation drains a dispatched claim before saving configurat expect(stoppedBeforeClaimReturned).toBe(false) await openSettings(page) await page.getByLabel('Lease renewal and job check interval (seconds; 0 disables)').fill('0') - await page.getByRole('button', { name: 'Save', exact: true }).click() - await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Save and use connection', exact: true }) + ).toBeDisabled() expect( await page.evaluate( () => @@ -1275,7 +1279,13 @@ test('Settings export restores the complete browser experience', async ({ page } expect(path).not.toBeNull() const exported = JSON.parse(readFileSync(path!, 'utf8')) expect(exported).toEqual({ - version: 2, + version: 3, + connections: { + activeConnectionId: expect.any(String), + connections: [ + { id: expect.any(String), name: new URL(postgrestUrl).host, config: exported.metaConfig }, + ], + }, metaConfig: { INKCRE_PGREST_URL: postgrestUrl, INKCRE_JWT_SECRET: jwtSecret, @@ -1312,7 +1322,7 @@ test('Settings export restores the complete browser experience', async ({ page } locale: localStorage.getItem('inkcre-locale'), })) ) - .toEqual({ metaConfig: exported.metaConfig, locale: 'en' }) + .toEqual({ metaConfig: { ...exported.metaConfig, ...exported.connections }, locale: 'en' }) await expect( page.getByRole('switch', { name: 'Enable telemetry', exact: true }) ).not.toBeChecked() @@ -1730,7 +1740,7 @@ test('an empty browser saves its connection across refresh and reads Peers', asy await page.getByLabel('JWT signing secret').fill(jwtSecret) await expect(page.getByLabel('Device readiness wait timeout (seconds)')).toHaveValue('5') await page.getByLabel('Device readiness wait timeout (seconds)').fill('45') - await page.getByRole('button', { name: 'Save', exact: true }).click() + await page.getByRole('button', { name: 'Save and use connection', exact: true }).click() await expect .poll(() => page.evaluate(