+
+ {t('settings.general.h5AccessStaleHostTitle')}
+
+
+ {h5AccessDiagnostics.suggestedHost
+ ? t('settings.general.h5AccessStaleHostBody', {
+ storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
+ })
+ : t('settings.general.h5AccessStaleHostNoSuggestion', {
+ storedHost: extractHostnameFromUrl(h5AccessDiagnostics.storedPublicBaseUrl) ?? h5AccessDiagnostics.storedPublicBaseUrl,
+ })}
+
+ {h5AccessDiagnostics.suggestedHost && (
+
+
+
+ )}
+
+ ) : null}
+
+ {h5AccessDiagnostics?.storedHostStaleness === 'proxy' ? (
+
((set, get) => ({
updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS,
network: DEFAULT_NETWORK_SETTINGS,
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
+ h5AccessDiagnostics: null,
h5AccessError: null,
responseLanguage: '',
uiZoom: readStoredAppZoomLevel(),
@@ -193,6 +196,7 @@ export const useSettingsStore = create
((set, get) => ({
updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy),
network: normalizeNetworkSettings(userSettings.network),
h5Access: h5AccessResult.settings,
+ h5AccessDiagnostics: h5AccessResult.diagnostics,
h5AccessError: h5AccessResult.error,
responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '',
isLoading: false,
@@ -208,7 +212,11 @@ export const useSettingsStore = create((set, get) => ({
fetchH5Access: async () => {
const result = await loadH5AccessSettings(get().h5Access)
- set({ h5Access: result.settings, h5AccessError: result.error })
+ set({
+ h5Access: result.settings,
+ h5AccessDiagnostics: result.diagnostics,
+ h5AccessError: result.error,
+ })
},
setPermissionMode: async (mode) => {
@@ -350,6 +358,7 @@ export const useSettingsStore = create((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
+ await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') })
@@ -365,6 +374,7 @@ export const useSettingsStore = create((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
+ await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') })
throw error
@@ -379,6 +389,7 @@ export const useSettingsStore = create((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
+ await refreshH5DiagnosticsSilent(set)
return token
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') })
@@ -394,6 +405,7 @@ export const useSettingsStore = create((set, get) => ({
h5Access: normalizeH5AccessSettings(settings),
h5AccessError: null,
})
+ await refreshH5DiagnosticsSilent(set)
} catch (error) {
set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') })
throw error
@@ -511,26 +523,45 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac
}
}
+async function refreshH5DiagnosticsSilent(
+ set: (partial: Partial) => void,
+): Promise {
+ // Best-effort diagnostics refresh. Failure here must not surface as an
+ // error on the main H5 action (enable/disable/regenerate/update), because
+ // the main action has already succeeded by the time we reach this point.
+ try {
+ const response = await h5AccessApi.get()
+ const diagnostics = response?.diagnostics ?? null
+ set({ h5AccessDiagnostics: diagnostics })
+ } catch {
+ // silent: keep previous diagnostics value
+ }
+}
+
async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{
settings: H5AccessSettings
+ diagnostics: H5AccessDiagnostics | null
error: string | null
}> {
try {
- const { settings } = await h5AccessApi.get()
+ const { settings, diagnostics } = await h5AccessApi.get()
return {
settings: normalizeH5AccessSettings(settings),
+ diagnostics: diagnostics ?? null,
error: null,
}
} catch (error) {
if (isLegacyH5EndpointError(error)) {
return {
settings: DEFAULT_H5_ACCESS_SETTINGS,
+ diagnostics: null,
error: null,
}
}
return {
settings: previousH5Access,
+ diagnostics: null,
error: getErrorMessage(error, 'Failed to load H5 access settings.'),
}
}
diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts
index 6624e74b..33b9175d 100644
--- a/desktop/src/types/settings.ts
+++ b/desktop/src/types/settings.ts
@@ -44,6 +44,16 @@ export type H5AccessSettings = {
publicBaseUrl: string | null
}
+export type H5HostStaleness = 'ok' | 'unreachable' | 'proxy' | 'unset'
+
+export type H5AccessDiagnostics = {
+ storedHostStaleness: H5HostStaleness
+ storedPublicBaseUrl: string | null
+ effectivePublicBaseUrl: string | null
+ suggestedHost: string | null
+ localInterfaceHosts: string[]
+}
+
export type DesktopTerminalStartupShell =
| 'system'
| 'pwsh'
diff --git a/src/server/__tests__/h5-access-api.test.ts b/src/server/__tests__/h5-access-api.test.ts
index 681255fb..2a434575 100644
--- a/src/server/__tests__/h5-access-api.test.ts
+++ b/src/server/__tests__/h5-access-api.test.ts
@@ -52,14 +52,26 @@ describe('/api/h5-access', () => {
const response = await api('GET', '/api/h5-access')
expect(response.status).toBe(200)
- await expect(response.json()).resolves.toEqual({
- settings: {
- enabled: false,
- tokenPreview: null,
- allowedOrigins: [],
- publicBaseUrl: null,
- },
+ const body = await response.json() as {
+ settings: unknown
+ diagnostics?: {
+ storedHostStaleness: string
+ storedPublicBaseUrl: string | null
+ effectivePublicBaseUrl: string | null
+ suggestedHost: string | null
+ localInterfaceHosts: string[]
+ }
+ }
+ expect(body.settings).toEqual({
+ enabled: false,
+ tokenPreview: null,
+ allowedOrigins: [],
+ publicBaseUrl: null,
})
+ expect(body.diagnostics).toBeDefined()
+ expect(body.diagnostics?.storedHostStaleness).toBe('unset')
+ expect(body.diagnostics?.storedPublicBaseUrl).toBeNull()
+ expect(Array.isArray(body.diagnostics?.localInterfaceHosts)).toBe(true)
})
test('enable returns raw token once and status stays sanitized', async () => {
diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts
index 99ddf5c9..10f34dfc 100644
--- a/src/server/__tests__/h5-access-service.test.ts
+++ b/src/server/__tests__/h5-access-service.test.ts
@@ -3,9 +3,12 @@ import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import {
+ classifyH5PublicBaseUrl,
+ collectLocalIPv4Hosts,
findPrivateLanAddress,
H5AccessService,
resolveEffectiveH5PublicBaseUrl,
+ validateH5PublicBaseUrl,
} from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
@@ -91,8 +94,12 @@ describe('H5AccessService', () => {
test('configured public URL overrides stale stored local URLs', async () => {
const service = new H5AccessService()
+ // Use loopback rather than a private-LAN IP so updateSettings's new
+ // local-interface validation does not reject the fixture URL. The intent
+ // here is still "stale stored URL overridden by configured" — loopback is
+ // treated as a proxy URL by classifyH5PublicBaseUrl and accepted as-is.
await service.updateSettings({
- publicBaseUrl: 'http://192.168.0.102:5179',
+ publicBaseUrl: 'http://127.0.0.1:5179',
})
process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'https://chat.example.com/app/'
@@ -291,6 +298,162 @@ describe('H5AccessService', () => {
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
})
+ test('stale stored LAN host falls back to auto when not bound to any local interface', () => {
+ // User saved 192.168.1.207 on a previous network; current interfaces only have 192.168.0.105.
+ // Effective URL must hop over to the auto-discovered host:port.
+ expect(resolveEffectiveH5PublicBaseUrl({
+ enabled: true,
+ storedPublicBaseUrl: 'http://192.168.1.207:55379',
+ configuredPublicBaseUrl: null,
+ autoPublicBaseUrl: 'http://192.168.0.105:55379',
+ localInterfaceHosts: ['192.168.0.105'],
+ })).toBe('http://192.168.0.105:55379')
+
+ // When stored host IS on a local interface, keep refreshing only the port (existing behavior).
+ expect(resolveEffectiveH5PublicBaseUrl({
+ enabled: true,
+ storedPublicBaseUrl: 'http://192.168.1.100:5179',
+ configuredPublicBaseUrl: null,
+ autoPublicBaseUrl: 'http://192.168.1.100:55379',
+ localInterfaceHosts: ['192.168.1.100'],
+ })).toBe('http://192.168.1.100:55379')
+
+ // Reverse-proxy stored URLs are never replaced by auto fallback, even if host is unreachable.
+ expect(resolveEffectiveH5PublicBaseUrl({
+ enabled: true,
+ storedPublicBaseUrl: 'https://h5.mydomain.com',
+ configuredPublicBaseUrl: null,
+ autoPublicBaseUrl: 'http://192.168.0.105:55379',
+ localInterfaceHosts: ['192.168.0.105'],
+ })).toBe('https://h5.mydomain.com')
+
+ // Backward compat: without localInterfaceHosts the legacy port-refresh-only path still works.
+ expect(resolveEffectiveH5PublicBaseUrl({
+ enabled: true,
+ storedPublicBaseUrl: 'http://192.168.1.207:5179',
+ configuredPublicBaseUrl: null,
+ autoPublicBaseUrl: 'http://192.168.0.105:55379',
+ })).toBe('http://192.168.1.207:55379')
+ })
+
+ test('classifyH5PublicBaseUrl distinguishes plain LAN, proxy and invalid', () => {
+ expect(classifyH5PublicBaseUrl('http://192.168.0.105:55379')).toBe('plain-lan')
+ expect(classifyH5PublicBaseUrl('http://10.0.0.5:8080')).toBe('plain-lan')
+ expect(classifyH5PublicBaseUrl('http://172.20.16.1:39876')).toBe('plain-lan')
+ // proxy: https / custom path / hostname instead of IP
+ expect(classifyH5PublicBaseUrl('https://h5.mydomain.com')).toBe('proxy')
+ expect(classifyH5PublicBaseUrl('http://192.168.0.105:8080/h5')).toBe('proxy')
+ expect(classifyH5PublicBaseUrl('https://192.168.0.105:8443')).toBe('proxy')
+ expect(classifyH5PublicBaseUrl('http://my-tunnel:8443')).toBe('proxy')
+ // invalid
+ expect(classifyH5PublicBaseUrl('not a url')).toBe('invalid')
+ expect(classifyH5PublicBaseUrl('ftp://example.com')).toBe('invalid')
+ expect(classifyH5PublicBaseUrl('http://user:pass@example.com')).toBe('invalid')
+ })
+
+ test('validateH5PublicBaseUrl: plain LAN host must be on local interfaces', () => {
+ const localHosts = ['192.168.0.105']
+
+ expect(validateH5PublicBaseUrl('http://192.168.0.105:55379', localHosts)).toEqual({
+ ok: true,
+ kind: 'plain-lan',
+ })
+
+ const stale = validateH5PublicBaseUrl('http://192.168.1.207:55379', localHosts)
+ expect(stale.ok).toBe(false)
+ if (!stale.ok) {
+ expect(stale.reason).toContain('192.168.1.207')
+ expect(stale.reason).toContain('192.168.0.105')
+ expect(stale.suggestedHost).toBe('192.168.0.105')
+ }
+ })
+
+ test('validateH5PublicBaseUrl: proxy URLs are accepted without local-interface checks', () => {
+ expect(validateH5PublicBaseUrl('https://h5.mydomain.com', ['192.168.0.105'])).toEqual({
+ ok: true,
+ kind: 'proxy',
+ })
+ expect(validateH5PublicBaseUrl('http://192.168.0.105:8080/h5', ['10.0.0.5'])).toEqual({
+ ok: true,
+ kind: 'proxy',
+ })
+ })
+
+ test('validateH5PublicBaseUrl: invalid URLs are rejected with suggested host', () => {
+ const result = validateH5PublicBaseUrl('ftp://example.com', ['192.168.0.105'])
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.suggestedHost).toBe('192.168.0.105')
+ }
+ })
+
+ test('updateSettings rejects a stale LAN host', async () => {
+ const service = new H5AccessService()
+ // Pick a private-LAN IP that is virtually guaranteed not to be on the
+ // test machine's interfaces (192.168.255.0/24 is reserved-ish in practice
+ // and we never see it in CI / dev environments).
+ await expect(
+ service.updateSettings({
+ publicBaseUrl: 'http://192.168.255.254:55379',
+ }),
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ })
+ })
+
+ test('collectLocalIPv4Hosts returns only non-internal IPv4 hosts', () => {
+ expect(collectLocalIPv4Hosts({
+ lo: [{
+ address: '127.0.0.1',
+ netmask: '255.0.0.0',
+ family: 'IPv4',
+ mac: '00:00:00:00:00:00',
+ internal: true,
+ cidr: '127.0.0.1/8',
+ }],
+ 'Wi-Fi': [{
+ address: '192.168.0.105',
+ netmask: '255.255.255.0',
+ family: 'IPv4',
+ mac: 'aa:bb:cc:dd:ee:ff',
+ internal: false,
+ cidr: '192.168.0.105/24',
+ }, {
+ address: 'fe80::1',
+ netmask: 'ffff:ffff:ffff:ffff::',
+ family: 'IPv6',
+ mac: 'aa:bb:cc:dd:ee:ff',
+ internal: false,
+ scopeid: 0,
+ cidr: 'fe80::1/64',
+ }],
+ })).toEqual(['192.168.0.105'])
+ })
+
+ test('getDiagnostics reports stale, ok, proxy and unset states', async () => {
+ const service = new H5AccessService()
+
+ // unset: no stored URL
+ let diag = await service.getDiagnostics()
+ expect(diag.storedHostStaleness).toBe('unset')
+ expect(diag.storedPublicBaseUrl).toBeNull()
+ expect(Array.isArray(diag.localInterfaceHosts)).toBe(true)
+
+ // proxy stored URL
+ await service.updateSettings({ publicBaseUrl: 'https://h5.mydomain.com' })
+ diag = await service.getDiagnostics()
+ expect(diag.storedHostStaleness).toBe('proxy')
+ expect(diag.storedPublicBaseUrl).toBe('https://h5.mydomain.com')
+
+ // ok: stored URL host is on local interfaces
+ const localHost = collectLocalIPv4Hosts()[0]
+ if (localHost) {
+ await service.updateSettings({ publicBaseUrl: `http://${localHost}:55379` })
+ diag = await service.getDiagnostics()
+ expect(diag.storedHostStaleness).toBe('ok')
+ }
+ })
+
test('concurrent h5 enable and provider managed settings update preserve both fields', async () => {
const h5Service = new H5AccessService()
const providerService = new ProviderService()
diff --git a/src/server/api/h5-access.ts b/src/server/api/h5-access.ts
index 8311b916..f011dd45 100644
--- a/src/server/api/h5-access.ts
+++ b/src/server/api/h5-access.ts
@@ -43,7 +43,11 @@ export async function handleH5AccessApi(
switch (sub) {
case undefined:
if (req.method === 'GET') {
- return Response.json({ settings: await h5AccessService.getSettings() })
+ const [settings, diagnostics] = await Promise.all([
+ h5AccessService.getSettings(),
+ h5AccessService.getDiagnostics(),
+ ])
+ return Response.json({ settings, diagnostics })
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
diff --git a/src/server/services/h5AccessService.ts b/src/server/services/h5AccessService.ts
index 30b3afba..eb0b89b3 100644
--- a/src/server/services/h5AccessService.ts
+++ b/src/server/services/h5AccessService.ts
@@ -16,6 +16,22 @@ export type H5AccessEnableResult = {
token: string
}
+export type H5HostStaleness = 'ok' | 'unreachable' | 'proxy' | 'unset'
+
+export type H5AccessDiagnostics = {
+ storedHostStaleness: H5HostStaleness
+ storedPublicBaseUrl: string | null
+ effectivePublicBaseUrl: string | null
+ suggestedHost: string | null
+ localInterfaceHosts: string[]
+}
+
+export type H5PublicBaseUrlClassification = 'plain-lan' | 'proxy'
+
+export type H5PublicBaseUrlValidationResult =
+ | { ok: true; kind: H5PublicBaseUrlClassification }
+ | { ok: false; reason: string; suggestedHost: string | null }
+
type StoredH5AccessSettings = H5AccessSettings & {
tokenHash: string | null
}
@@ -44,10 +60,50 @@ function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
storedPublicBaseUrl: settings.publicBaseUrl,
configuredPublicBaseUrl: resolveConfiguredPublicBaseUrl(),
autoPublicBaseUrl: resolveAutoLanPublicBaseUrl(),
+ localInterfaceHosts: collectLocalIPv4Hosts(),
}),
}
}
+function describeH5AccessDiagnostics(stored: StoredH5AccessSettings): H5AccessDiagnostics {
+ const localInterfaceHosts = collectLocalIPv4Hosts()
+ const autoPublicBaseUrl = resolveAutoLanPublicBaseUrl()
+ const configuredPublicBaseUrl = resolveConfiguredPublicBaseUrl()
+ const effectivePublicBaseUrl = resolveEffectiveH5PublicBaseUrl({
+ enabled: stored.enabled,
+ storedPublicBaseUrl: stored.publicBaseUrl,
+ configuredPublicBaseUrl,
+ autoPublicBaseUrl,
+ localInterfaceHosts,
+ })
+
+ const suggestedHost = pickPreferredLanHost(localInterfaceHosts)
+ let storedHostStaleness: H5HostStaleness = 'unset'
+ if (stored.publicBaseUrl) {
+ const classification = classifyH5PublicBaseUrl(stored.publicBaseUrl)
+ if (classification === 'plain-lan') {
+ try {
+ const u = new URL(stored.publicBaseUrl)
+ storedHostStaleness = localInterfaceHosts.includes(u.hostname) ? 'ok' : 'unreachable'
+ } catch {
+ storedHostStaleness = 'unreachable'
+ }
+ } else if (classification === 'proxy') {
+ storedHostStaleness = 'proxy'
+ } else {
+ storedHostStaleness = 'unreachable'
+ }
+ }
+
+ return {
+ storedHostStaleness,
+ storedPublicBaseUrl: stored.publicBaseUrl,
+ effectivePublicBaseUrl,
+ suggestedHost,
+ localInterfaceHosts,
+ }
+}
+
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
@@ -157,11 +213,13 @@ export function resolveEffectiveH5PublicBaseUrl({
storedPublicBaseUrl,
configuredPublicBaseUrl,
autoPublicBaseUrl,
+ localInterfaceHosts,
}: {
enabled: boolean
storedPublicBaseUrl: string | null
configuredPublicBaseUrl: string | null
autoPublicBaseUrl: string | null
+ localInterfaceHosts?: string[]
}): string | null {
if (!enabled) {
return storedPublicBaseUrl
@@ -179,6 +237,17 @@ export function resolveEffectiveH5PublicBaseUrl({
return autoPublicBaseUrl
}
+ // Stale-host fallback: stored is a plain private-LAN URL pointing at an IP
+ // that no longer belongs to any of this machine's interfaces (e.g. user
+ // switched Wi-Fi). Fall back to the auto-discovered URL without overwriting
+ // the stored value, so reconnecting to the original network restores it.
+ if (
+ Array.isArray(localInterfaceHosts) &&
+ isStaleLanPublicBaseUrl(storedPublicBaseUrl, localInterfaceHosts)
+ ) {
+ return autoPublicBaseUrl
+ }
+
const refreshedLanUrl = refreshLanPublicBaseUrlPort(storedPublicBaseUrl, autoPublicBaseUrl)
if (refreshedLanUrl) {
return refreshedLanUrl
@@ -187,6 +256,41 @@ export function resolveEffectiveH5PublicBaseUrl({
return storedPublicBaseUrl
}
+function isStaleLanPublicBaseUrl(value: string, localInterfaceHosts: string[]): boolean {
+ if (classifyH5PublicBaseUrl(value) !== 'plain-lan') return false
+ try {
+ const hostname = new URL(value).hostname
+ return !localInterfaceHosts.includes(hostname)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Classify a stored or input H5 publicBaseUrl. A "plain-lan" URL is a bare
+ * `http://:` with no path and no userinfo — we know we
+ * can reach it only if the host is bound to one of our local interfaces.
+ * Everything else (https, custom path, hostname/proxy URL) is treated as a
+ * user-managed proxy URL we cannot reachability-check.
+ */
+export function classifyH5PublicBaseUrl(value: string): H5PublicBaseUrlClassification | 'invalid' {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return 'invalid'
+ }
+
+ if (!['http:', 'https:'].includes(url.protocol)) return 'invalid'
+ if (url.username || url.password) return 'invalid'
+
+ const path = url.pathname.replace(/\/+$/, '')
+ if (url.protocol === 'http:' && path === '' && isPrivateIPv4(url.hostname)) {
+ return 'plain-lan'
+ }
+ return 'proxy'
+}
+
function isLocalPublicBaseUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname
@@ -231,6 +335,70 @@ function refreshLanPublicBaseUrlPort(storedPublicBaseUrl: string, autoPublicBase
type NetworkInterfaces = ReturnType
+export function collectLocalIPv4Hosts(networkInterfaces: NetworkInterfaces = os.networkInterfaces()): string[] {
+ const hosts: string[] = []
+ for (const entries of Object.values(networkInterfaces)) {
+ for (const entry of entries ?? []) {
+ if (entry.family === 'IPv4' && !entry.internal) {
+ hosts.push(entry.address)
+ }
+ }
+ }
+ return hosts
+}
+
+function pickPreferredLanHost(localHosts: string[]): string | null {
+ for (const host of localHosts) {
+ if (host.startsWith('192.168.')) return host
+ }
+ for (const host of localHosts) {
+ if (host.startsWith('10.')) return host
+ }
+ for (const host of localHosts) {
+ if (is172PrivateIPv4(host)) return host
+ }
+ return localHosts[0] ?? null
+}
+
+export function validateH5PublicBaseUrl(
+ publicBaseUrl: string,
+ localInterfaceHosts: string[] = collectLocalIPv4Hosts(),
+): H5PublicBaseUrlValidationResult {
+ const kind = classifyH5PublicBaseUrl(publicBaseUrl)
+ if (kind === 'invalid') {
+ return {
+ ok: false,
+ reason: `Invalid H5 publicBaseUrl: ${publicBaseUrl}`,
+ suggestedHost: pickPreferredLanHost(localInterfaceHosts),
+ }
+ }
+
+ if (kind === 'plain-lan') {
+ try {
+ const hostname = new URL(publicBaseUrl).hostname
+ if (!localInterfaceHosts.includes(hostname)) {
+ const suggested = pickPreferredLanHost(localInterfaceHosts)
+ const availableList = localInterfaceHosts.length > 0
+ ? localInterfaceHosts.join(', ')
+ : 'none'
+ return {
+ ok: false,
+ reason: `H5 host ${hostname} is not bound to any local network interface on this machine. Available LAN IPv4: ${availableList}`,
+ suggestedHost: suggested,
+ }
+ }
+ } catch {
+ return {
+ ok: false,
+ reason: `Invalid H5 publicBaseUrl: ${publicBaseUrl}`,
+ suggestedHost: pickPreferredLanHost(localInterfaceHosts),
+ }
+ }
+ }
+
+ return { ok: true, kind }
+}
+
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
@@ -436,14 +604,25 @@ export class H5AccessService {
}): Promise {
return this.managedSettingsService.updateSettings(async (current) => {
const h5Access = normalizeStoredSettings(current.h5Access)
+ let nextPublicBaseUrl: string | null
+ if (input.publicBaseUrl === undefined) {
+ nextPublicBaseUrl = h5Access.publicBaseUrl
+ } else {
+ nextPublicBaseUrl = normalizePublicBaseUrl(input.publicBaseUrl)
+ if (nextPublicBaseUrl !== null) {
+ const validation = validateH5PublicBaseUrl(nextPublicBaseUrl)
+ if (!validation.ok) {
+ throw ApiError.badRequest(validation.reason)
+ }
+ }
+ }
+
const nextSettings: StoredH5AccessSettings = {
...h5Access,
allowedOrigins: input.allowedOrigins === undefined
? h5Access.allowedOrigins
: normalizeAllowedOrigins(input.allowedOrigins),
- publicBaseUrl: input.publicBaseUrl === undefined
- ? h5Access.publicBaseUrl
- : normalizePublicBaseUrl(input.publicBaseUrl),
+ publicBaseUrl: nextPublicBaseUrl,
}
return {
@@ -456,6 +635,11 @@ export class H5AccessService {
})
}
+ async getDiagnostics(): Promise {
+ const { h5Access } = await this.readStoredSettings()
+ return describeH5AccessDiagnostics(h5Access)
+ }
+
async validateToken(token: string | null | undefined): Promise {
if (!token) {
return false