From 2770267dc7d0262e378f8aa23828adae6415df3b Mon Sep 17 00:00:00 2001 From: Neko Date: Wed, 19 Aug 2026 03:55:15 +0800 Subject: [PATCH] fix(stage-tamagotchi): sync pairing settings and QR state (#2318) --- .../pages/settings/connection/index.vue | 73 ++--- .../connection/server-channel-qr-card.vue | 19 +- .../settings/server-channel-options.test.ts | 40 --- .../stores/settings/server-channel-options.ts | 28 -- .../stores/settings/server-channel.test.ts | 59 +++- .../stores/settings/server-channel.ts | 11 +- docs/ai/context/ui-components.md | 2 + packages/i18n/src/locales/en/settings.yaml | 41 +-- .../i18n/src/locales/zh-Hans/settings.yaml | 36 +-- .../src/scenarios/settings-connection.ts | 2 +- .../scenarios/connection/settings/index.vue | 9 + .../src/components/form/field/field-input.vue | 5 + .../ui/src/components/form/input/input.vue | 4 + pnpm-lock.yaml | 256 ++++++++++++++++-- 14 files changed, 379 insertions(+), 206 deletions(-) delete mode 100644 apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.test.ts delete mode 100644 apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.ts diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/connection/index.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/connection/index.vue index d9eaa6413..6cfc8f045 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/connection/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/connection/index.vue @@ -1,7 +1,7 @@ diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.test.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.test.ts deleted file mode 100644 index e5b3201c8..000000000 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { hostnameFromExposureMode, serverChannelExposureModeFromHostname } from './server-channel-options' - -describe('serverChannelExposureModeFromHostname', () => { - it('maps loopback hostnames to this-device mode', () => { - expect(serverChannelExposureModeFromHostname('127.0.0.1')).toBe('this-device') - expect(serverChannelExposureModeFromHostname('localhost')).toBe('this-device') - expect(serverChannelExposureModeFromHostname('::1')).toBe('this-device') - expect(serverChannelExposureModeFromHostname('')).toBe('this-device') - }) - - it('maps wildcard bind hostnames to all mode', () => { - expect(serverChannelExposureModeFromHostname('0.0.0.0')).toBe('all') - expect(serverChannelExposureModeFromHostname('::')).toBe('all') - }) - - it('maps custom hostnames to advanced mode', () => { - expect(serverChannelExposureModeFromHostname('192.168.1.25')).toBe('advanced') - expect(serverChannelExposureModeFromHostname('airi.local')).toBe('advanced') - }) -}) - -describe('hostnameFromExposureMode', () => { - it('returns a secure loopback hostname for this-device mode', () => { - expect(hostnameFromExposureMode('this-device', '192.168.1.25')).toBe('127.0.0.1') - }) - - it('returns an all-interfaces hostname for all mode', () => { - expect(hostnameFromExposureMode('all', '127.0.0.1')).toBe('0.0.0.0') - }) - - it('uses the manual hostname for advanced mode and trims whitespace', () => { - expect(hostnameFromExposureMode('advanced', ' 192.168.1.25 ')).toBe('192.168.1.25') - }) - - it('falls back to loopback when advanced mode is empty', () => { - expect(hostnameFromExposureMode('advanced', ' ')).toBe('127.0.0.1') - }) -}) diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.ts deleted file mode 100644 index 5ca5b1b2d..000000000 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel-options.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type ServerChannelExposureMode = 'this-device' | 'all' | 'advanced' - -const LOOPBACK_HOSTNAMES = new Set(['', '127.0.0.1', 'localhost', '::1']) -const ALL_INTERFACE_HOSTNAMES = new Set(['0.0.0.0', '::']) - -export function serverChannelExposureModeFromHostname(hostname?: string): ServerChannelExposureMode { - const normalizedHostname = hostname?.trim() ?? '' - - if (LOOPBACK_HOSTNAMES.has(normalizedHostname)) - return 'this-device' - - if (ALL_INTERFACE_HOSTNAMES.has(normalizedHostname)) - return 'all' - - return 'advanced' -} - -export function hostnameFromExposureMode(mode: ServerChannelExposureMode, manualHostname?: string) { - if (mode === 'all') - return '0.0.0.0' - - if (mode === 'advanced') { - const normalizedHostname = manualHostname?.trim() ?? '' - return normalizedHostname || '127.0.0.1' - } - - return '127.0.0.1' -} diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts index fa9948860..6979b3447 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.test.ts @@ -1,4 +1,4 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createPinia, disposePinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' @@ -50,15 +50,18 @@ vi.mock('vue-sonner', () => ({ describe('useServerChannelSettingsStore', async () => { const { useServerChannelSettingsStore } = await import('./server-channel') + let pinia: ReturnType beforeEach(() => { - setActivePinia(createPinia()) + pinia = createPinia() + setActivePinia(pinia) invokeMocks.getConfig.mockClear() invokeMocks.applyConfig.mockClear() toastError.mockClear() }) afterEach(() => { + disposePinia(pinia) vi.restoreAllMocks() }) @@ -81,4 +84,56 @@ describe('useServerChannelSettingsStore', async () => { expect(toastError).toHaveBeenCalledWith('apply failed') }) }) + + it('publishes the applied config only after the main process accepts the change', async () => { + let resolveApply: ((config: { + authToken: string + hostname: string + tlsConfig: Record | null + }) => void) | undefined + invokeMocks.applyConfig.mockImplementationOnce(async () => await new Promise((resolve) => { + resolveApply = resolve + })) + + const store = useServerChannelSettingsStore() + + await vi.waitFor(() => { + expect(store.appliedConfig).toEqual({ + authToken: 'existing-token', + hostname: '127.0.0.1', + tlsConfig: null, + }) + }) + + store.hostname = '0.0.0.0' + await nextTick() + + // ROOT CAUSE: + // + // The QR card watched the optimistic hostname and requested its payload + // while the main process still restarted the server with the new config. + // The request read the old loopback config and failed. The accepted config + // did not change the hostname again, so the QR card never retried. + // We fixed this by publishing the accepted config after the IPC request + // completes. The QR card watches that accepted snapshot. + expect(store.appliedConfig).toEqual({ + authToken: 'existing-token', + hostname: '127.0.0.1', + tlsConfig: null, + }) + + resolveApply?.({ + authToken: 'existing-token', + hostname: '0.0.0.0', + tlsConfig: null, + }) + + await vi.waitFor(() => { + expect(store.appliedConfig).toEqual({ + authToken: 'existing-token', + hostname: '0.0.0.0', + tlsConfig: null, + }) + }) + }) }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts index 3223e2b63..0705ca2cc 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/settings/server-channel.ts @@ -4,7 +4,7 @@ import { errorMessageFrom } from '@moeru/std' import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' import { useLocalStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { ref, watch } from 'vue' +import { shallowRef, watch } from 'vue' import { toast } from 'vue-sonner' import { @@ -17,8 +17,9 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan const tlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null) const hostname = useLocalStorage('settings/server-channel/hostname', '127.0.0.1') const authToken = useLocalStorage('settings/server-channel/auth-token', '') - const lastApplyError = ref(null) - const syncingWithServer = ref(false) + const lastApplyError = shallowRef(null) + const syncingWithServer = shallowRef(false) + const appliedConfig = shallowRef() const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig) const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig) @@ -32,6 +33,9 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan if (config.authToken !== undefined) { authToken.value = config.authToken } + // This snapshot changes only after the main process accepts the config. + // Consumers that read runtime state must not react to the optimistic fields above. + appliedConfig.value = config syncingWithServer.value = false } @@ -74,6 +78,7 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan return { lastApplyError, + appliedConfig, refreshServerChannelConfig, tlsConfig, hostname, diff --git a/docs/ai/context/ui-components.md b/docs/ai/context/ui-components.md index 97c05556e..870347d8d 100644 --- a/docs/ai/context/ui-components.md +++ b/docs/ai/context/ui-components.md @@ -258,6 +258,7 @@ Basic text/number input. | `type` | `InputType?` | — | HTML input type | | `variant` | `'primary' \| 'secondary' \| 'primary-dimmed'` | `'primary'` | Visual variant | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size | +| `disabled` | `boolean?` | — | Disable editing and focus | **v-model**: `modelValue: string | number` @@ -517,6 +518,7 @@ All Field components wrap a base input with `label`, `description`, and consiste | `description` | `string?` | — | Helper text | | `placeholder` | `string?` | — | Placeholder | | `required` | `boolean?` | — | Required indicator | +| `disabled` | `boolean?` | — | Disable editing and focus | | `type` | `InputType?` | — | Input type | | `autocomplete` | `string?` | — | Native autocomplete hint | | `inputClass` | `string?` | — | Custom input class | diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 89c5e6817..bd6ea767b 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -1470,39 +1470,29 @@ pages: description: Configure WebSocket server connection title: Connection websocket-url: - description: Full address of the WebSocket server (e.g., ws://localhost:6121/ws) - label: WebSocket Server Address + description: The address that this app uses to connect to the local server. + label: Server Address placeholder: ws://localhost:6121/ws server-hostname: - label: Expose On Network - description: >- - Choose whether the AIRI gateway only listens on this device or can be - reached from your local network. - advanced-label: Bind Hostname - advanced-description: >- - Use a specific hostname or IP address when you need manual network - binding. - options: - this-device: This device - all: All - advanced: Advanced + label: Expose To All + description: Allow other devices on the local network to connect to this server. server-auth-token: - label: Auth Token + label: Pairing Token description: >- - Security token required by remote clients that connect to this gateway. - When left empty, AIRI generates a new token automatically. + Other devices use this token to pair with this server. AIRI generates a + token when this field is empty. placeholder: Auto-generate qr: - title: Connect from Stage Pocket + title: Pairing description: >- - Scan this QR code from Stage Pocket to try all reachable local network - addresses and save the first working one. + Scan this QR code with the mobile app. The app tries each local address + and saves the first working address. refresh: Refresh - image-alt: AIRI server channel connection QR code + image-alt: AIRI pairing QR code candidates: Candidate addresses token-hint: >- - The QR code includes the auth token so Stage Pocket can authenticate - after connecting. Do not share it publicly. + The QR code includes the pairing token. Do not share the QR code + publicly. errors: title: QR code unavailable unavailable: Failed to prepare the connection QR code. @@ -1844,10 +1834,9 @@ tachie: render-scale: Render Scale shadow: Theme-colored drop shadow websocket-secure-enabled: - title: Enable Secure WebSocket (WSS) + title: Serve as Secure WebSocket (WSS) description: >- - Enable WSS (WebSocket Secure) to allow HTTPS connections for mobile (pocket) - in development mode. This will automatically generate a self-signed + Use WSS for secure mobile connections. AIRI creates a self-signed certificate. wip: title: Work in Progress diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index f00bd07ba..62f8a7e0b 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -1415,34 +1415,23 @@ pages: description: 配置 WebSocket 服务器连接 title: 连接 websocket-url: - description: WebSocket 服务器的完整地址(例如,ws://localhost:6121/ws) - label: WebSocket 服务器地址 + description: 此应用连接本地服务器时使用的地址。 + label: 服务器地址 placeholder: ws://localhost:6121/ws server-hostname: - label: 是否暴露在连接的网络下(WiFi,有线) - description: >- - 选择是否只允许此设备与 AIRI 进行配对,或是也允许来自其他本地网络的设备进行配对 - advanced-label: 会监听的 hostname - advanced-description: >- - 当您需要手动绑定时,使用指定的主机名或 IP 地址。 - options: - this-device: 仅此设备 - all: 本地网络所有人 - advanced: 高级配置 + label: 向所有设备开放 + description: 允许本地网络中的其他设备连接此服务器。 server-auth-token: - label: 认证凭据 - description: >- - 连接和配对需要安全令牌。如果留空,AIRI 自动生成一个新的令牌。 + label: 配对令牌 + description: 其他设备使用此令牌与服务器配对。如果此字段为空,AIRI 会生成一个令牌。 placeholder: 自动生成 qr: - title: 从 Pocket(口袋)版配对 - description: >- - 从 Pocket(口袋)版扫描此二维码以进行配对 + title: 配对 + description: 使用移动应用扫描此二维码。应用会尝试每个本地地址,并保存第一个可用地址。 refresh: 刷新 - image-alt: AIRI 配对码 + image-alt: AIRI 配对二维码 candidates: 备选连接地址 - token-hint: >- - 二维码包含了认证凭据,所以 Pocket(口袋)版可以自动配对,请不要公开分享此二维码。 + token-hint: 二维码包含配对令牌。请勿公开分享此二维码。 errors: title: 二维码暂不可用 unavailable: 无法生成配对二维码 @@ -1773,9 +1762,8 @@ tachie: render-scale: 渲染缩放 shadow: 主题色动态阴影 websocket-secure-enabled: - title: 启用安全WebSocket (WSS) - description: >- - 启用 WSS (WebSocket Secure) 以便在开发模式下允许移动设备(Pocket) 连接HTTPS 。这将自动生成一个自签名证书。 + title: 作为安全 WebSocket (WSS) 提供服务 + description: 使用 WSS 建立安全的移动端连接。AIRI 会创建一个自签名证书。 wip: title: 正在开发中 description: >- diff --git a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts index 18b46ecde..71b863c96 100644 --- a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts +++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts @@ -1,6 +1,6 @@ import { defineStageTamagotchiScenario } from '../context' -const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i +const websocketServerAddressPattern = /(?:WebSocket )?Server Address|(?:WebSocket )?服务器地址/i export default defineStageTamagotchiScenario({ id: 'settings-connection', diff --git a/packages/stage-ui/src/components/scenarios/connection/settings/index.vue b/packages/stage-ui/src/components/scenarios/connection/settings/index.vue index 3f74b0062..9f540b93a 100644 --- a/packages/stage-ui/src/components/scenarios/connection/settings/index.vue +++ b/packages/stage-ui/src/components/scenarios/connection/settings/index.vue @@ -6,6 +6,13 @@ import { useI18n } from 'vue-i18n' import { useModsServerChannelStore } from '../../../../stores/mods/api/channel-server' +const props = withDefaults(defineProps<{ + /** Disables the server address field when the runtime owns its value. */ + serverAddressDisabled?: boolean +}>(), { + serverAddressDisabled: false, +}) + const { t } = useI18n() const { websocketUrl } = storeToRefs(useModsServerChannelStore()) @@ -28,11 +35,13 @@ const websocketUrlModel = computed({ + diff --git a/packages/ui/src/components/form/field/field-input.vue b/packages/ui/src/components/form/field/field-input.vue index 71ab4fe5b..ddc3046d8 100644 --- a/packages/ui/src/components/form/field/field-input.vue +++ b/packages/ui/src/components/form/field/field-input.vue @@ -18,6 +18,8 @@ const props = withDefaults(defineProps<{ * through other means (e.g. all fields are required). */ required?: boolean + /** Disables editing and focus on the input. */ + disabled?: boolean /** * Suppress the `*` indicator next to the label without disabling the * underlying HTML5 `required` validation. Useful for forms where every @@ -60,6 +62,7 @@ const modelValue = defineModel({ required: false }) :placeholder="props.placeholder" :autocomplete="props.autocomplete" :required="props.required" + :disabled="props.disabled" :class="props.inputClass" /> ({ required: false }) :placeholder="props.placeholder" :autocomplete="props.autocomplete" :required="props.required" + :disabled="props.disabled" :class="props.inputClass" />