Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Codex Web" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icons/codexui-icon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/pwa-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="./icons/codexui-icon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="./icons/pwa-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./icons/apple-touch-icon.png" />
<title>Codex Web</title>
</head>
<body class="bg-slate-950">
Expand Down
12 changes: 6 additions & 6 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,32 @@
"name": "Codex Web",
"short_name": "Codex Web",
"description": "A lightweight web interface for Codex that can be installed like an app in Chrome.",
"start_url": "/",
"scope": "/",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#020617",
"icons": [
{
"src": "/icons/pwa-192x192.png",
"src": "./icons/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/pwa-512x512.png",
"src": "./icons/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/apple-touch-icon.png",
"src": "./icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-512x512.png",
"src": "./icons/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
Expand Down
23 changes: 16 additions & 7 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
const CACHE_NAME = 'codexweb-shell-v2'
const APP_SHELL_PATHS = ['/', '/manifest.webmanifest']
const CACHE_NAME = 'codexweb-shell-v3'
const APP_SCOPE_URL = new URL(self.registration.scope)
const appUrl = (path) => new URL(path.replace(/^\/+/u, ''), APP_SCOPE_URL)
const APP_SHELL_URLS = [APP_SCOPE_URL.href, appUrl('manifest.webmanifest').href]
const MANIFEST_PATHNAME = appUrl('manifest.webmanifest').pathname
const STATIC_DESTINATIONS = new Set(['document', 'script', 'style', 'image', 'font'])
const BYPASS_PREFIXES = ['/codex-api/', '/codex-local-image', '/codex-local-file', '/codex-local-browse/', '/codex-local-edit/']
const BYPASS_PREFIXES = [
'codex-api/',
'codex-local-image',
'codex-local-file',
'codex-local-browse/',
'codex-local-edit/',
].map((path) => appUrl(path).pathname)

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL_PATHS)),
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL_URLS)),
)
self.skipWaiting()
})
Expand Down Expand Up @@ -41,7 +50,7 @@ self.addEventListener('fetch', (event) => {
return
}

if (STATIC_DESTINATIONS.has(request.destination) || url.pathname === '/manifest.webmanifest') {
if (STATIC_DESTINATIONS.has(request.destination) || url.pathname === MANIFEST_PATHNAME) {
event.respondWith(staleWhileRevalidate(request))
}
})
Expand All @@ -50,10 +59,10 @@ async function networkFirstNavigation(request) {
const cache = await caches.open(CACHE_NAME)
try {
const response = await fetch(request)
cache.put('/', response.clone())
cache.put(APP_SCOPE_URL.href, response.clone())
return response
} catch {
return (await cache.match('/')) || Response.error()
return (await cache.match(APP_SCOPE_URL.href)) || Response.error()
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,7 @@ import { useDesktopState } from './composables/useDesktopState'
import { useMobile } from './composables/useMobile'
import { useUiLanguage } from './composables/useUiLanguage'
import { useFeedbackDiagnostics } from './composables/useFeedbackDiagnostics'
import { appHttpUrl } from './api/appUrl'
import {
checkoutGitBranch,
cloneGithubRepository,
Expand Down Expand Up @@ -2787,7 +2788,7 @@ function onBrowseThreadFiles(threadId: string): void {
}
}
if (!targetCwd || typeof window === 'undefined') return
window.open(`/codex-local-browse${encodeURI(targetCwd)}`, '_blank', 'noopener,noreferrer')
window.open(appHttpUrl(`/codex-local-browse${encodeURI(targetCwd)}`), '_blank', 'noopener,noreferrer')
}

function getProjectCwd(projectName: string): string {
Expand Down Expand Up @@ -2819,7 +2820,7 @@ function toWorktreeFolderNameDraft(projectName: string): string {
function onBrowseProjectFiles(projectName: string): void {
const targetCwd = getProjectCwd(projectName)
if (!targetCwd || typeof window === 'undefined') return
window.open(`/codex-local-browse${encodeURI(targetCwd)}`, '_blank', 'noopener,noreferrer')
window.open(appHttpUrl(`/codex-local-browse${encodeURI(targetCwd)}`), '_blank', 'noopener,noreferrer')
}

async function onSaveProject(projectName: string): Promise<void> {
Expand Down
63 changes: 63 additions & 0 deletions src/api/appUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { appFetch, appHttpUrl, appWebSocketUrl, isAppPathname } from './appUrl'

function setBrowserLocation(baseURI: string, protocol = 'https:'): void {
vi.stubGlobal('document', { baseURI })
vi.stubGlobal('location', { protocol })
}

afterEach(() => {
vi.unstubAllGlobals()
})

describe('app URL helpers', () => {
it('resolves HTTP paths below the JupyterHub application base', () => {
setBrowserLocation('https://host/user/alice/codex/')

expect(appHttpUrl('codex-api/rpc')).toBe(
'https://host/user/alice/codex/codex-api/rpc',
)
})

it('removes leading slashes and document query/hash state', () => {
setBrowserLocation('https://host/user/alice/codex?from=hub#/thread/123')

expect(appHttpUrl('/codex-api/rpc')).toBe(
'https://host/user/alice/codex/codex-api/rpc',
)
})

it('uses the same base path for secure WebSockets', () => {
setBrowserLocation('https://host/user/alice/codex/')

expect(appWebSocketUrl('/codex-api/ws')).toBe(
'wss://host/user/alice/codex/codex-api/ws',
)
})

it('recognizes both proxied and legacy root-relative application paths', () => {
setBrowserLocation('https://host/user/alice/codex/')

expect(isAppPathname('/user/alice/codex/codex-local-image', '/codex-local-image')).toBe(true)
expect(isAppPathname('/codex-local-image', '/codex-local-image')).toBe(true)
expect(isAppPathname('/user/bob/codex/codex-local-image', '/codex-local-image')).toBe(false)
})

it('keeps root-relative paths when no browser document exists', () => {
expect(appHttpUrl('/codex-api/rpc')).toBe('/codex-api/rpc')
expect(appWebSocketUrl('/codex-api/ws')).toBe('ws://localhost/codex-api/ws')
})

it('routes root-relative fetches through the application base', async () => {
setBrowserLocation('http://host/user/alice/codex/', 'http:')
const fetchMock = vi.fn().mockResolvedValue(new Response())
vi.stubGlobal('fetch', fetchMock)

await appFetch('/codex-api/rpc', { method: 'POST' })

expect(fetchMock).toHaveBeenCalledWith(
'http://host/user/alice/codex/codex-api/rpc',
{ method: 'POST' },
)
})
})
44 changes: 44 additions & 0 deletions src/api/appUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
function appBaseUrl(): URL | null {
if (typeof document === 'undefined') return null

const base = new URL(document.baseURI)
base.hash = ''
base.search = ''
if (!base.pathname.endsWith('/')) {
base.pathname += '/'
}
return base
}

function normalizeAppPath(path: string): string {
return path.replace(/^\/+/u, '')
}

export function appHttpUrl(path: string): string {
const normalizedPath = normalizeAppPath(path)
const base = appBaseUrl()
return base ? new URL(normalizedPath, base).toString() : `/${normalizedPath}`
}

export function appWebSocketUrl(path: string): string {
const url = new URL(appHttpUrl(path), 'http://localhost')
if (typeof location !== 'undefined') {
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
} else {
url.protocol = 'ws:'
}
return url.toString()
}

export function isAppPathname(pathname: string, appPath: string): boolean {
const rootPathname = new URL(`http://localhost/${normalizeAppPath(appPath)}`).pathname
const resolvedPathname = new URL(appHttpUrl(appPath), 'http://localhost').pathname
return pathname === rootPathname || pathname === resolvedPathname
}

export function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const resolvedInput = typeof input === 'string' && input.startsWith('/')
? appHttpUrl(input)
: input
return globalThis.fetch(resolvedInput, init)
}
5 changes: 3 additions & 2 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
subscribeRpcNotifications,
type RpcNotification,
} from './codexRpcClient'
import { appFetch as fetch, appHttpUrl, isAppPathname } from './appUrl'
import type {
CollaborationModeListResponse,
ConfigReadResponse,
Expand Down Expand Up @@ -1755,7 +1756,7 @@ function extractLocalImagePathFromUrl(value: string): string | null {
if (!value) return null
try {
const parsed = new URL(value, 'http://localhost')
if (parsed.pathname !== '/codex-local-image') return null
if (!isAppPathname(parsed.pathname, '/codex-local-image')) return null
const path = parsed.searchParams.get('path')?.trim() ?? ''
return path.length > 0 ? path : null
} catch {
Expand Down Expand Up @@ -3081,7 +3082,7 @@ export async function openProjectRoot(path: string, options?: { createIfMissing?

export function getProjectZipDownloadUrl(cwd: string): string {
const query = new URLSearchParams({ cwd })
return `/codex-api/project-zip?${query.toString()}`
return appHttpUrl(`/codex-api/project-zip?${query.toString()}`)
}

function readDownloadFileName(response: Response, fallback: string): string {
Expand Down
6 changes: 3 additions & 3 deletions src/api/codexRpcClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RpcEnvelope, RpcMethodCatalog } from '../types/codex'
import { appFetch as fetch, appHttpUrl, appWebSocketUrl } from './appUrl'
import { CodexApiError, extractErrorMessage } from './codexErrors'

type RpcRequestBody = {
Expand Down Expand Up @@ -191,7 +192,7 @@ export function subscribeRpcNotifications(onNotification: (value: RpcNotificatio
const attachSse = (attempt = 0) => {
if (typeof EventSource === 'undefined' || closed) return
cleanup?.()
const source = new EventSource('/codex-api/events')
const source = new EventSource(appHttpUrl('/codex-api/events'))
let isConnectionClosed = false

source.onmessage = (event) => {
Expand Down Expand Up @@ -233,8 +234,7 @@ export function subscribeRpcNotifications(onNotification: (value: RpcNotificatio
}

cleanup?.()
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(`${protocol}//${window.location.host}/codex-api/ws`)
const socket = new WebSocket(appWebSocketUrl('/codex-api/ws'))
let didOpen = false
let intentionallyClosed = false
let fallbackTimer: number | null = window.setTimeout(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/api/normalizers/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
UiProjectGroup,
UiThread,
} from '../../types/codex'
import { appHttpUrl } from '../appUrl'
import { normalizePathForComparison, normalizePathForUi, toProjectName } from '../../pathUtils.js'

function toIso(seconds: number): string {
Expand Down Expand Up @@ -74,7 +75,7 @@ function extractCodexUserRequestText(value: string): string {
}

function toLocalImageUrl(path: string): string {
return `/codex-local-image?path=${encodeURIComponent(path)}`
return appHttpUrl(`/codex-local-image?path=${encodeURIComponent(path)}`)
}

function toImageGenerationUrl(value: string): string {
Expand Down
5 changes: 3 additions & 2 deletions src/components/content/DirectoryHub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appHttpUrl } from '../../api/appUrl'
import {
getDirectoryComposioStatus,
getMethodCatalog,
Expand Down Expand Up @@ -1169,10 +1170,10 @@ function showToast(text: string, type: 'success' | 'error' = 'success'): void {

function localAssetSrc(path: string): string {
if (!path) return ''
if (path.startsWith('connectors://')) return `/codex-api/connector-logo?src=${encodeURIComponent(path)}`
if (path.startsWith('connectors://')) return appHttpUrl(`/codex-api/connector-logo?src=${encodeURIComponent(path)}`)
if (/^https?:\/\//i.test(path) || path.startsWith('data:')) return path
if (!path.startsWith('/')) return ''
return `/codex-local-image?path=${encodeURIComponent(path)}`
return appHttpUrl(`/codex-local-image?path=${encodeURIComponent(path)}`)
}

function pluginIconSrc(plugin: DirectoryPluginSummary | null): string {
Expand Down
3 changes: 2 additions & 1 deletion src/components/content/SkillCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

<script setup lang="ts">
import { computed } from 'vue'
import { appHttpUrl } from '../../api/appUrl'
import { useUiLanguage } from '../../composables/useUiLanguage'
import IconTablerFolder from '../icons/IconTablerFolder.vue'

Expand Down Expand Up @@ -86,7 +87,7 @@ const skillDirPath = computed(() => {
function onBrowse(): void {
const dir = skillDirPath.value
if (!dir) return
window.open(`/codex-local-browse${encodeURI(dir)}`, '_blank', 'noopener,noreferrer')
window.open(appHttpUrl(`/codex-local-browse${encodeURI(dir)}`), '_blank', 'noopener,noreferrer')
}

const publishedLabel = computed(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/content/SkillDetailModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { appFetch as fetch, appHttpUrl } from '../../api/appUrl'
import { useUiLanguage } from '../../composables/useUiLanguage'
import IconTablerX from '../icons/IconTablerX.vue'

Expand Down Expand Up @@ -216,7 +217,7 @@ function onTry(): void {
function onBrowseFiles(): void {
const dir = skillDirPath.value
if (!dir) return
window.open(`/codex-local-browse${encodeURI(dir)}`, '_blank', 'noopener,noreferrer')
window.open(appHttpUrl(`/codex-local-browse${encodeURI(dir)}`), '_blank', 'noopener,noreferrer')
}
</script>

Expand Down
1 change: 1 addition & 0 deletions src/components/content/SkillsHub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { appFetch as fetch } from '../../api/appUrl'
import IconTablerChevronRight from '../icons/IconTablerChevronRight.vue'
import SkillCard from './SkillCard.vue'
import SkillDetailModal, { type HubSkill } from './SkillDetailModal.vue'
Expand Down
5 changes: 3 additions & 2 deletions src/components/content/ThreadComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { appHttpUrl } from '../../api/appUrl'
import type {
CollaborationModeKind,
CollaborationModeOption,
Expand Down Expand Up @@ -1214,7 +1215,7 @@ function skillMarkdownPath(path: string): string {
function openSkillMarkdown(skill: SkillItem): void {
const markdownPath = skillMarkdownPath(skill.path)
if (!markdownPath || typeof window === 'undefined') return
window.open(`/codex-local-browse${encodeURI(markdownPath)}`, '_blank', 'noopener,noreferrer')
window.open(appHttpUrl(`/codex-local-browse${encodeURI(markdownPath)}`), '_blank', 'noopener,noreferrer')
}

function removeFileAttachment(fsPath: string): void {
Expand Down Expand Up @@ -1347,7 +1348,7 @@ async function attachImageFile(file: File, sessionToken: number): Promise<void>
{
id: createAttachmentId(),
name: normalizedFile.name,
url: `/codex-local-image?path=${encodeURIComponent(serverPath)}`,
url: appHttpUrl(`/codex-local-image?path=${encodeURIComponent(serverPath)}`),
},
]
recordAttachmentBatchResult('success')
Expand Down
Loading