,
+ document.body,
+ )
)}
- {worktreeMenuOpen && worktreeMenuPos && createPortal(
-
+ {worktreeMenuOpen && worktreeMenuPos && (
+ isMobileBrowser ? (
+
setWorktreeMenuOpen(false)}
+ title={t('repoLaunch.selectWorktree')}
+ closeLabel={t('tabs.close')}
+ panelRef={worktreeMenuRef}
+ contentClassName="py-2"
+ >
+
+
+
+
+
+
+ ) : createPortal(
+
)
diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts
index 023dcbab..2594d8ea 100644
--- a/desktop/src/lib/desktopRuntime.test.ts
+++ b/desktop/src/lib/desktopRuntime.test.ts
@@ -23,6 +23,7 @@ import {
initializeDesktopServerUrl,
isLoopbackHostname,
requiresH5AuthForServerUrl,
+ saveAndVerifyH5Connection,
} from './desktopRuntime'
describe('desktopRuntime browser H5 bootstrap', () => {
@@ -48,11 +49,18 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true)
+ expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true)
+ })
+
+ it('only lets localhost browser WebUI bypass H5 auth for private LAN servers', () => {
+ expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false)
+ expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false)
+ expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false)
+ expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true)
+ expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true)
})
it('clears an invalid token but preserves the remembered remote server URL', async () => {
- window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp')
- window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
@@ -60,7 +68,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
)
- await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
+ await expect(saveAndVerifyH5Connection('https://public.example.com/app', 'stale-token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com/app',
message: 'The saved H5 token is no longer valid.',
@@ -88,10 +96,9 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
- window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com')
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch
- const startup = expect(initializeDesktopServerUrl()).rejects.toMatchObject({
+ const startup = expect(saveAndVerifyH5Connection('https://unreachable.example.com', 'h5_token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://unreachable.example.com',
message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.',
@@ -106,14 +113,12 @@ describe('desktopRuntime browser H5 bootstrap', () => {
})
it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => {
- window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com')
- window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'h5_token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch'))
- await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
+ await expect(saveAndVerifyH5Connection('https://public.example.com', 'h5_token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com',
message: 'Unable to verify the H5 access token.',
@@ -124,4 +129,34 @@ describe('desktopRuntime browser H5 bootstrap', () => {
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
+
+ it('lets localhost browser WebUI connect to a LAN-bound server without H5 token', async () => {
+ window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(null, { status: 200 }),
+ ) as typeof fetch
+
+ await expect(initializeDesktopServerUrl()).resolves.toBe('http://192.168.0.102:28670')
+
+ expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://192.168.0.102:28670')
+ expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
+ expect(clientMocks.postVerify).not.toHaveBeenCalled()
+ expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBeNull()
+ expect(globalThis.fetch).toHaveBeenCalledWith('http://192.168.0.102:28670/api/status', {
+ cache: 'no-store',
+ })
+ })
+
+ it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => {
+ window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
+ globalThis.fetch = vi.fn()
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
+ .mockResolvedValueOnce(new Response(null, { status: 401 })) as typeof fetch
+
+ await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
+ name: 'H5ConnectionRequiredError',
+ serverUrl: 'http://192.168.0.102:28670',
+ reason: 'missing-token',
+ } satisfies Partial
)
+ })
})
diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts
index 22a1b079..1b7e37b0 100644
--- a/desktop/src/lib/desktopRuntime.ts
+++ b/desktop/src/lib/desktopRuntime.ts
@@ -151,6 +151,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
}
if (!browserH5Runtime) {
+ await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
return requestedUrl
}
@@ -203,6 +204,19 @@ async function verifyH5Access() {
await api.post<{ ok: true }>('/api/h5-access/verify')
}
+async function ensureBrowserApiAccessibleWithoutH5(serverUrl: string) {
+ const response = await fetch(`${serverUrl}/api/status`, {
+ cache: 'no-store',
+ })
+ if (response.status === 401) {
+ throw new H5ConnectionRequiredError(
+ 'Enter your H5 token to continue.',
+ serverUrl,
+ 'missing-token',
+ )
+ }
+}
+
function normalizeServerUrl(value: string | null | undefined) {
const trimmed = value?.trim()
if (!trimmed) return null
@@ -224,14 +238,54 @@ export function isLoopbackHostname(hostname: string) {
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
}
-export function requiresH5AuthForServerUrl(serverUrl: string) {
+export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {
try {
- return !isLoopbackHostname(new URL(serverUrl).hostname)
+ const serverHostname = new URL(serverUrl).hostname
+ if (isLoopbackHostname(serverHostname)) {
+ return false
+ }
+ if (browserHostname && isLoopbackHostname(browserHostname) && isPrivateNetworkHostname(serverHostname)) {
+ return false
+ }
+ return true
} catch {
return false
}
}
+function isPrivateNetworkHostname(hostname: string) {
+ const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
+
+ if (normalized === '0.0.0.0') {
+ return true
+ }
+
+ const ipv4Parts = normalized.split('.')
+ if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) {
+ const octets = ipv4Parts.map((part) => Number(part))
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
+ return false
+ }
+ const a = octets[0] ?? -1
+ const b = octets[1] ?? -1
+ return (
+ a === 10 ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168) ||
+ (a === 169 && b === 254)
+ )
+ }
+
+ return normalized.startsWith('fc') ||
+ normalized.startsWith('fd') ||
+ normalized.startsWith('fe80:')
+}
+
+function getBrowserHostname() {
+ if (typeof window === 'undefined') return null
+ return window.location.hostname
+}
+
function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
if (error instanceof H5ConnectionRequiredError) {
return error
diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx
index 70326f93..f5462f85 100644
--- a/desktop/src/pages/EmptySession.test.tsx
+++ b/desktop/src/pages/EmptySession.test.tsx
@@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
wsOnMessage: vi.fn(),
wsSend: vi.fn(),
wsDisconnect: vi.fn(),
+ isMobile: false,
+ isTauriRuntime: false,
}))
vi.mock('../api/sessions', () => ({
@@ -51,6 +53,14 @@ vi.mock('../api/websocket', () => ({
},
}))
+vi.mock('../hooks/useMobileViewport', () => ({
+ useMobileViewport: () => mocks.isMobile,
+}))
+
+vi.mock('../lib/desktopRuntime', () => ({
+ isTauriRuntime: () => mocks.isTauriRuntime,
+}))
+
vi.mock('../components/shared/DirectoryPicker', () => ({
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (