fix(desktop): refresh stale H5 LAN URLs

H5 access could keep returning a previously saved private-network URL even after the packaged desktop server moved to a new ephemeral LAN port. The public settings now prefer the current auto-detected LAN URL for local/private stored addresses while preserving explicit public reverse-proxy URLs.

Constraint: Packaged desktop H5 access uses an ephemeral server port and auto-publishes a LAN URL for phones on the same network.
Rejected: Always overwrite stored publicBaseUrl | public reverse-proxy deployments still need manual stable URLs.
Confidence: high
Scope-risk: narrow
Tested: bun test src/server/__tests__/h5-access-service.test.ts
Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts src/server/__tests__/h5-access-auth.test.ts
Tested: bun run check:server
Tested: manual LAN smoke with 0.0.0.0:5188 correcting stale 192.168.0.102:5179 to 192.168.0.102:5188
Not-tested: physical phone browser on local WiFi
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 20:48:03 +08:00
parent 339d0d860d
commit 16112e83f3
2 changed files with 104 additions and 3 deletions

View File

@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { H5AccessService } from '../services/h5AccessService.js'
import {
H5AccessService,
resolveEffectiveH5PublicBaseUrl,
} from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
let tmpDir: string
@ -85,6 +88,41 @@ describe('H5AccessService', () => {
expect(result.settings.publicBaseUrl).toBe('http://192.168.1.20:28670')
})
test('configured public URL overrides stale stored local URLs', async () => {
const service = new H5AccessService()
await service.updateSettings({
publicBaseUrl: 'http://192.168.0.102:5179',
})
process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'https://chat.example.com/app/'
const result = await service.enable()
expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app')
})
test('auto LAN mode replaces stale local public URLs but keeps public reverse proxies', () => {
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'http://192.168.0.102:5179',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.102:39876',
})).toBe('http://192.168.0.102:39876')
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'http://127.0.0.1:5179',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.102:39876',
})).toBe('http://192.168.0.102:39876')
expect(resolveEffectiveH5PublicBaseUrl({
enabled: true,
storedPublicBaseUrl: 'https://chat.example.com/app',
configuredPublicBaseUrl: null,
autoPublicBaseUrl: 'http://192.168.0.102:39876',
})).toBe('https://chat.example.com/app')
})
test('regenerateToken invalidates the previous token', async () => {
const service = new H5AccessService()

View File

@ -39,7 +39,12 @@ function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
enabled: settings.enabled,
tokenPreview: settings.tokenPreview,
allowedOrigins: settings.allowedOrigins,
publicBaseUrl: settings.publicBaseUrl ?? (settings.enabled ? resolveAutoPublicBaseUrl() : null),
publicBaseUrl: resolveEffectiveH5PublicBaseUrl({
enabled: settings.enabled,
storedPublicBaseUrl: settings.publicBaseUrl,
configuredPublicBaseUrl: resolveConfiguredPublicBaseUrl(),
autoPublicBaseUrl: resolveAutoLanPublicBaseUrl(),
}),
}
}
@ -121,7 +126,7 @@ function normalizePublicBaseUrl(input: unknown): string | null {
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`
}
function resolveAutoPublicBaseUrl(): string | null {
function resolveConfiguredPublicBaseUrl(): string | null {
const configured = process.env.CLAUDE_H5_PUBLIC_BASE_URL
if (configured) {
try {
@ -131,6 +136,10 @@ function resolveAutoPublicBaseUrl(): string | null {
}
}
return null
}
function resolveAutoLanPublicBaseUrl(): string | null {
if (process.env.CLAUDE_H5_AUTO_PUBLIC_URL !== '1') {
return null
}
@ -143,6 +152,60 @@ function resolveAutoPublicBaseUrl(): string | null {
return `http://${host}:${ProviderService.getServerPort()}`
}
export function resolveEffectiveH5PublicBaseUrl({
enabled,
storedPublicBaseUrl,
configuredPublicBaseUrl,
autoPublicBaseUrl,
}: {
enabled: boolean
storedPublicBaseUrl: string | null
configuredPublicBaseUrl: string | null
autoPublicBaseUrl: string | null
}): string | null {
if (!enabled) {
return storedPublicBaseUrl
}
if (configuredPublicBaseUrl) {
return configuredPublicBaseUrl
}
if (!autoPublicBaseUrl) {
return storedPublicBaseUrl
}
if (!storedPublicBaseUrl || isLocalOrPrivatePublicBaseUrl(storedPublicBaseUrl)) {
return autoPublicBaseUrl
}
return storedPublicBaseUrl
}
function isLocalOrPrivatePublicBaseUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname
.trim()
.replace(/^\[/, '')
.replace(/\]$/, '')
.toLowerCase()
return isLocalOrPrivateHost(hostname)
} catch {
return false
}
}
function isLocalOrPrivateHost(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:')
}
function findPrivateLanAddress(): string | null {
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries ?? []) {