Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="OpenCode" />
<meta name="mobile-web-app-capable" content="yes" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand Down
162 changes: 162 additions & 0 deletions packages/app/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const CACHE_NAME = "opencode-v3"
const STATIC_ASSETS = [
"/",
"/favicon.svg",
"/favicon-96x96.png",
"/apple-touch-icon.png",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
]

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.warn("Failed to cache some assets:", err)
})
}),
)
self.skipWaiting()
})

self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== CACHE_NAME && key.startsWith("opencode-")).map((key) => caches.delete(key)),
)
}),
)
self.clients.claim()
})

self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url)

// Skip non-GET requests
if (event.request.method !== "GET") return

// Skip API requests and SSE connections
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/event")) return

// Skip cross-origin requests
if (url.origin !== self.location.origin) return

// Stale-while-revalidate for HTML (app shell)
// This prevents the app from refreshing when returning from background on mobile
// The cached version is served immediately while updating in the background
if (event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html")) {
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
})
.catch(() => null)

// Return cached immediately if available, otherwise wait for network
// This prevents blank screen when offline and fast return when online
return cached || fetchPromise || caches.match("/")
}),
)
return
}

// Cache-first for hashed assets (Vite adds content hashes to /assets/*)
if (url.pathname.startsWith("/assets/")) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached
return fetch(event.request).then((response) => {
if (response.ok) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
})
}),
)
return
}

// Stale-while-revalidate for unhashed static assets (favicon, icons, etc.)
// Serves cached version immediately but updates cache in background
if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|gif|webp|woff|woff2|ttf|eot|ico|aac|mp3|wav)$/)) {
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
})
return cached || fetchPromise
}),
)
return
}

// Network-first for everything else
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
})
.catch(() => caches.match(event.request)),
)
})

// Web Push notification handler
self.addEventListener("push", (event) => {
if (!event.data) return

const data = event.data.json()
const title = data.title || "OpenCode"
const options = {
body: data.body || "",
icon: "/favicon-96x96.png",
badge: "/favicon-96x96.png",
tag: data.tag || "opencode-push",
data: { href: data.href },
// Vibrate on mobile: short-long-short pattern
vibrate: [100, 50, 100],
// Keep notification until user interacts
requireInteraction: data.requireInteraction ?? false,
}

event.waitUntil(self.registration.showNotification(title, options))
})

// Handle notification click - focus or open the app
self.addEventListener("notificationclick", (event) => {
event.notification.close()

const href = event.notification.data?.href || "/"

event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
// Try to focus an existing window
for (const client of clients) {
if (client.url.includes(self.location.origin) && "focus" in client) {
client.focus()
// Navigate to the href if provided
if (href && href !== "/") {
client.navigate(href)
}
return
}
}
// No existing window, open a new one
return self.clients.openWindow(href)
}),
)
})
37 changes: 25 additions & 12 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"

import { Suspense } from "solid-js"

const Home = lazy(() => import("@/pages/home"))
Expand All @@ -33,10 +33,32 @@ const Loading = () => <div class="size-full flex items-center justify-center tex

declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
__OPENCODE__?: {
updaterEnabled?: boolean
serverPassword?: string
port?: number
serverReady?: boolean
serverUrl?: string
}
}
}

function getDefaultServerUrl(override?: string) {
if (override) return override
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`

// For remote access (e.g., mobile via Tailscale), use same hostname on port 4096
if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
return `${location.protocol}//${location.hostname}:4096`
}

return window.location.origin
}

export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
Expand Down Expand Up @@ -66,17 +88,8 @@ function ServerKey(props: ParentProps) {
}

export function AppInterface(props: { defaultUrl?: string }) {
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`

return window.location.origin
}

return (
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerProvider defaultUrl={getDefaultServerUrl(props.defaultUrl)}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
Expand Down
Loading