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"
/>