diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 529a8a62..f2066b63 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -857,6 +857,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) + expect(within(section).getByLabelText('Access host / IP')).toHaveValue('https://phone.example/app') await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Copy H5 URL' })) }) @@ -878,7 +879,35 @@ describe('Settings > General tab', () => { expect(within(section).getByText('H5 unavailable')).toBeInTheDocument() }) - it('updates H5 public URL from General settings', async () => { + it('updates H5 host by reusing the current service port', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + tokenPreview: 'h5a1b2c3', + allowedOrigins: [], + publicBaseUrl: 'http://172.20.16.1:54064', + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + + const section = screen.getByRole('region', { name: 'H5 Access' }) + expect(within(section).getByLabelText('Current port')).toHaveValue('54064') + fireEvent.change(within(section).getByLabelText('Access host / IP'), { + target: { value: '192.168.1.100' }, + }) + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) + }) + + expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ + publicBaseUrl: 'http://192.168.1.100:54064', + }) + }) + + it('still accepts a full H5 public URL for reverse proxy setups', async () => { useSettingsStore.setState({ h5Access: { enabled: false, @@ -892,7 +921,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('H5 Access')) const section = screen.getByRole('region', { name: 'H5 Access' }) - fireEvent.change(within(section).getByLabelText('Public URL'), { + fireEvent.change(within(section).getByLabelText('Access host / IP'), { target: { value: 'https://phone.example/app' }, }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 7283b2de..27169c42 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -879,12 +879,16 @@ export const en = { 'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.', 'settings.general.h5AccessHideToken': 'Hide token', 'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.', + 'settings.general.h5AccessPublicHost': 'Access host / IP', + 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', + 'settings.general.h5AccessCurrentPort': 'Current port', + 'settings.general.h5AccessCurrentPortUnknown': 'Auto', 'settings.general.h5AccessPublicUrl': 'Public URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': 'Allowed origins', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.', - 'settings.general.h5AccessOpenHint': 'Leave blank to use the detected LAN URL. Set this only when you expose H5 through a stable address.', + 'settings.general.h5AccessOpenHint': 'For LAN access, change only the host / IP; the current service port is reused. Enter a full URL for reverse proxies.', 'settings.general.h5AccessSave': 'Save H5 settings', 'settings.general.h5AccessUrl': 'H5 URL', 'settings.general.h5AccessQrTitle': 'Phone connection', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index daaf39a0..ceaa479f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -881,12 +881,16 @@ export const zh: Record = { 'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。', 'settings.general.h5AccessHideToken': '隐藏令牌', 'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。', + 'settings.general.h5AccessPublicHost': '访问主机 / IP', + 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', + 'settings.general.h5AccessCurrentPort': '当前端口', + 'settings.general.h5AccessCurrentPortUnknown': '自动', 'settings.general.h5AccessPublicUrl': '公开访问 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': '允许的来源', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。', - 'settings.general.h5AccessOpenHint': '留空时使用自动探测的局域网地址。只有通过固定地址暴露 H5 时才需要填写。', + 'settings.general.h5AccessOpenHint': '普通局域网访问只改主机 / IP,端口使用当前服务端口。反向代理可直接填完整 URL。', 'settings.general.h5AccessSave': '保存 H5 设置', 'settings.general.h5AccessUrl': 'H5 链接', 'settings.general.h5AccessQrTitle': '手机连接', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2be7029d..5ce407e1 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -73,6 +73,58 @@ function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string } } +function isLanH5BaseUrl(url: URL): boolean { + return url.protocol === 'http:' && + !!url.port && + ( + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname.startsWith('10.') || + url.hostname.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(url.hostname) || + url.hostname.startsWith('169.254.') + ) +} + +function extractH5AccessAddressDraft(baseUrl: string | null): string { + if (!baseUrl) return '' + + try { + const url = new URL(baseUrl) + return isLanH5BaseUrl(url) ? url.hostname : baseUrl + } catch { + return baseUrl + } +} + +function extractH5AccessPort(baseUrl: string | null): string | null { + if (!baseUrl) return null + + try { + const url = new URL(baseUrl) + return url.port || null + } catch { + return null + } +} + +function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null { + const trimmed = draft.trim() + if (!trimmed) return null + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed + + try { + const current = currentBaseUrl ? new URL(currentBaseUrl) : null + if (!current) return trimmed + + const port = current.port ? `:${current.port}` : '' + const path = current.pathname === '/' ? '' : current.pathname.replace(/\/+$/, '') + return `${current.protocol}//${trimmed}${port}${path}` + } catch { + return trimmed + } +} + export function Settings() { const [activeTab, setActiveTab] = useState('providers') const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) @@ -2465,7 +2517,7 @@ function H5AccessSettings() { } = useSettingsStore() const t = useTranslation() const addToast = useUIStore((s) => s.addToast) - const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '') + const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl)) const [h5GeneratedToken, setH5GeneratedToken] = useState(null) const [h5TokenVisible, setH5TokenVisible] = useState(false) const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false) @@ -2476,10 +2528,12 @@ function H5AccessSettings() { () => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken), [h5AccessUrl, h5GeneratedToken], ) - const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') + const h5AccessPort = extractH5AccessPort(h5AccessUrl) + const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl) + const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null) useEffect(() => { - setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '') + setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl)) }, [h5Access]) useEffect(() => { @@ -2518,7 +2572,7 @@ function H5AccessSettings() { const handleH5SettingsSave = async () => { await runH5Action(async () => { await updateH5AccessSettings({ - publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, + publicBaseUrl: h5NextPublicBaseUrl, }) }) } @@ -2624,13 +2678,22 @@ function H5AccessSettings() {
- setH5PublicBaseUrlDraft(event.target.value)} - /> +
+ setH5PublicBaseUrlDraft(event.target.value)} + /> + +

{t('settings.general.h5AccessOpenHint')} diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts index 79a57746..f76b96a4 100644 --- a/src/server/__tests__/h5-access-service.test.ts +++ b/src/server/__tests__/h5-access-service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { + findPrivateLanAddress, H5AccessService, resolveEffectiveH5PublicBaseUrl, } from '../services/h5AccessService.js' @@ -100,10 +101,10 @@ describe('H5AccessService', () => { expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app') }) - test('auto LAN mode replaces stale local public URLs but keeps public reverse proxies', () => { + test('auto LAN mode fills blank or loopback URLs but preserves manual LAN URLs', () => { expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, - storedPublicBaseUrl: 'http://192.168.0.102:5179', + storedPublicBaseUrl: null, configuredPublicBaseUrl: null, autoPublicBaseUrl: 'http://192.168.0.102:39876', })).toBe('http://192.168.0.102:39876') @@ -115,6 +116,13 @@ describe('H5AccessService', () => { autoPublicBaseUrl: 'http://192.168.0.102:39876', })).toBe('http://192.168.0.102:39876') + expect(resolveEffectiveH5PublicBaseUrl({ + enabled: true, + storedPublicBaseUrl: 'http://192.168.1.100:54064', + configuredPublicBaseUrl: null, + autoPublicBaseUrl: 'http://172.20.16.1:39876', + })).toBe('http://192.168.1.100:54064') + expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, storedPublicBaseUrl: 'https://chat.example.com/app', @@ -123,6 +131,35 @@ describe('H5AccessService', () => { })).toBe('https://chat.example.com/app') }) + test('auto LAN detection prefers physical adapters over WSL and Docker virtual adapters', () => { + expect(findPrivateLanAddress({ + 'vEthernet (WSL)': [{ + address: '172.20.16.1', + netmask: '255.255.240.0', + family: 'IPv4', + mac: '00:15:5d:00:00:01', + internal: false, + cidr: '172.20.16.1/20', + }], + 'Docker Desktop': [{ + address: '172.17.0.1', + netmask: '255.255.0.0', + family: 'IPv4', + mac: '02:42:ac:11:00:01', + internal: false, + cidr: '172.17.0.1/16', + }], + 'Wi-Fi': [{ + address: '192.168.1.100', + netmask: '255.255.255.0', + family: 'IPv4', + mac: 'aa:bb:cc:dd:ee:ff', + internal: false, + cidr: '192.168.1.100/24', + }], + })).toBe('192.168.1.100') + }) + test('regenerateToken invalidates the previous token', async () => { const service = new H5AccessService() diff --git a/src/server/services/h5AccessService.ts b/src/server/services/h5AccessService.ts index 5e03194e..09b3247a 100644 --- a/src/server/services/h5AccessService.ts +++ b/src/server/services/h5AccessService.ts @@ -175,47 +175,89 @@ export function resolveEffectiveH5PublicBaseUrl({ return storedPublicBaseUrl } - if (!storedPublicBaseUrl || isLocalOrPrivatePublicBaseUrl(storedPublicBaseUrl)) { + if (!storedPublicBaseUrl || isLocalPublicBaseUrl(storedPublicBaseUrl)) { return autoPublicBaseUrl } return storedPublicBaseUrl } -function isLocalOrPrivatePublicBaseUrl(value: string): boolean { +function isLocalPublicBaseUrl(value: string): boolean { try { const hostname = new URL(value).hostname .trim() .replace(/^\[/, '') .replace(/\]$/, '') .toLowerCase() - return isLocalOrPrivateHost(hostname) + return isLocalHost(hostname) } catch { return false } } -function isLocalOrPrivateHost(hostname: string): boolean { +function isLocalHost(hostname: string): boolean { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0' || - isPrivateIPv4(hostname) || - hostname.startsWith('fc') || - hostname.startsWith('fd') || - hostname.startsWith('fe80:') + hostname === '::' } -function findPrivateLanAddress(): string | null { - for (const entries of Object.values(os.networkInterfaces())) { +type NetworkInterfaces = ReturnType + +const PHYSICAL_INTERFACE_RE = /\b(wi-?fi|wlan|wireless|ethernet|lan|en\d+|eth\d+)\b/i +const VIRTUAL_INTERFACE_RE = /\b(wsl|docker|hyper-?v|veth|vethernet|virtual|virtualbox|vmware|podman|container|bridge|br-|tailscale|zerotier|utun|vpn)\b/i + +export function findPrivateLanAddress(networkInterfaces: NetworkInterfaces = os.networkInterfaces()): string | null { + const candidates: Array<{ + address: string + interfaceName: string + index: number + score: number + }> = [] + + let index = 0 + for (const [interfaceName, entries] of Object.entries(networkInterfaces)) { for (const entry of entries ?? []) { if (entry.family !== 'IPv4' || entry.internal || !isPrivateIPv4(entry.address)) { continue } - return entry.address + + candidates.push({ + address: entry.address, + interfaceName, + index, + score: scoreLanAddressCandidate(interfaceName, entry.address), + }) + index += 1 } } - return null + + candidates.sort((a, b) => b.score - a.score || a.index - b.index) + return candidates[0]?.address ?? null +} + +function scoreLanAddressCandidate(interfaceName: string, address: string): number { + let score = 0 + + if (PHYSICAL_INTERFACE_RE.test(interfaceName)) { + score += 100 + } + if (VIRTUAL_INTERFACE_RE.test(interfaceName)) { + score -= 200 + } + + if (address.startsWith('192.168.')) { + score += 30 + } else if (address.startsWith('10.')) { + score += 20 + } else if (is172PrivateIPv4(address)) { + score += 10 + } else if (address.startsWith('169.254.')) { + score -= 100 + } + + return score } function isPrivateIPv4(address: string): boolean { @@ -227,12 +269,22 @@ function isPrivateIPv4(address: string): boolean { const [a = -1, b = -1] = parts.map((part) => Number(part)) return ( a === 10 || - (a === 172 && b >= 16 && b <= 31) || + is172PrivateIPv4(address) || (a === 192 && b === 168) || (a === 169 && b === 254) ) } +function is172PrivateIPv4(address: string): boolean { + const parts = address.split('.') + if (parts.length !== 4 || !parts.every((part) => /^\d+$/.test(part))) { + return false + } + + const [a = -1, b = -1] = parts.map((part) => Number(part)) + return a === 172 && b >= 16 && b <= 31 +} + function normalizeStoredSettings(value: unknown): StoredH5AccessSettings { if (!isRecord(value)) { return { ...DEFAULT_STORED_SETTINGS }