Merge H5 LAN address fix into local main

This commit is contained in:
程序员阿江(Relakkes) 2026-05-22 19:46:59 +08:00
commit a0f702aaeb
6 changed files with 219 additions and 30 deletions

View File

@ -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(<Settings />)
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' },
})

View File

@ -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',

View File

@ -881,12 +881,16 @@ export const zh: Record<TranslationKey, string> = {
'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': '手机连接',

View File

@ -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<SettingsTab>('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<string | null>(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() {
</div>
<div className="mt-4 grid grid-cols-1 gap-3">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicUrl')}
value={h5PublicBaseUrlDraft}
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicHost')}
value={h5PublicBaseUrlDraft}
placeholder={t('settings.general.h5AccessPublicHostPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
/>
<Input
id="h5-access-current-port"
label={t('settings.general.h5AccessCurrentPort')}
value={h5AccessPort ?? t('settings.general.h5AccessCurrentPortUnknown')}
readOnly
className="text-[var(--color-text-tertiary)]"
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessOpenHint')}

View File

@ -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()

View File

@ -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<typeof os.networkInterfaces>
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 }