diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000..4d4a0d1407 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000..1b6fbae4f3 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000..4d4a0d1407 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000000..d667507629 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,75 @@ +const CACHE_NAME = "omniroute-pwa-v1"; +const APP_SHELL = [ + "/", + "/offline", + "/manifest.webmanifest", + "/icon-512.png", + "/apple-touch-icon.png", +]; +const EXCLUDED_PATH_PREFIXES = ["/api/", "/a2a", "/dashboard/endpoint"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") { + return; + } + + const requestUrl = new URL(event.request.url); + const isSameOrigin = requestUrl.origin === self.location.origin; + const isExcludedPath = EXCLUDED_PATH_PREFIXES.some((prefix) => + requestUrl.pathname.startsWith(prefix), + ); + const destination = event.request.destination; + const isStaticAsset = ["style", "script", "image", "font"].includes(destination); + const isNavigateRequest = event.request.mode === "navigate"; + + // Never cache API/dashboard traffic with potentially auth-sensitive content. + if (!isSameOrigin || isExcludedPath) { + return; + } + + event.respondWith( + (async () => { + if (isNavigateRequest) { + try { + return await fetch(event.request); + } catch { + return (await caches.match("/offline")) || Response.error(); + } + } + + if (!isStaticAsset) { + return fetch(event.request); + } + + const cachedResponse = await caches.match(event.request); + if (cachedResponse) { + return cachedResponse; + } + + const networkResponse = await fetch(event.request); + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone)); + } + return networkResponse; + })(), + ); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e259b92c67..6168ea1a2f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,12 +5,19 @@ import { NextIntlClientProvider } from "next-intl"; import { getMessages, getLocale } from "next-intl/server"; import { RTL_LOCALES } from "@/i18n/config"; import { getSettings } from "@/lib/db/settings"; +import type { Viewport } from "next"; +import { PwaRegister } from "@/shared/components/PwaRegister"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter", }); +export const viewport: Viewport = { + themeColor: "#0b0f1a", + viewportFit: "cover", +}; + export async function generateMetadata() { const settings = await getSettings(); const instanceName = settings?.instanceName || "OmniRoute"; @@ -20,9 +27,25 @@ export async function generateMetadata() { title: `${instanceName} — AI Gateway for Multi-Provider LLMs`, description: "OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.", + manifest: "/manifest.webmanifest", + applicationName: instanceName, + appleWebApp: { + capable: true, + title: instanceName, + statusBarStyle: "black-translucent", + }, + other: { + "mobile-web-app-capable": "yes", + }, icons: { - icon: customFaviconUrl ? "/api/settings/favicon" : "/favicon.svg", - apple: "/apple-touch-icon.svg", + icon: customFaviconUrl + ? "/api/settings/favicon" + : [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/favicon.svg", type: "image/svg+xml" }, + { url: "/icon-512.png", type: "image/png", sizes: "512x512" }, + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], }, }; } @@ -67,6 +90,7 @@ export default async function RootLayout({ children }) { Skip to content + {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000000..66c767a6a4 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,29 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "OmniRoute", + short_name: "OmniRoute", + description: + "OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.", + start_url: "/", + scope: "/", + display: "fullscreen", + orientation: "any", + background_color: "#0b0f1a", + theme_color: "#0b0f1a", + icons: [ + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "any maskable", + }, + { + src: "/apple-touch-icon.png", + sizes: "180x180", + type: "image/png", + }, + ], + }; +} diff --git a/src/shared/components/PwaRegister.tsx b/src/shared/components/PwaRegister.tsx new file mode 100644 index 0000000000..a737fb196a --- /dev/null +++ b/src/shared/components/PwaRegister.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect } from "react"; + +export function PwaRegister() { + useEffect(() => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) { + return; + } + + navigator.serviceWorker.register("/sw.js").catch(() => { + // Ignore registration failures to avoid blocking app rendering. + }); + }, []); + + return null; +}