diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 780ad64d..1daeebea 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -10,6 +10,7 @@ import { AppSidebar } from "@multica/ui/components/app-sidebar"; import { ThemeProvider } from "@multica/ui/components/theme-provider"; import { Toaster } from "@multica/ui/components/ui/sonner"; import { HubSidebar } from "@multica/ui/components/hub-sidebar"; +import { ServiceWorkerRegister } from "./sw-register"; const gatewayUrl = process.env.NEXT_PUBLIC_GATEWAY_URL; if (gatewayUrl) { @@ -37,6 +38,14 @@ const playfair = Playfair_Display({ export const metadata: Metadata = { title: "Multica", description: "Distributed AI agent framework", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Multica", + }, + icons: { + apple: "/icon-192x192.png", + }, }; export default function RootLayout({ @@ -65,6 +74,7 @@ export default function RootLayout({ + ); diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts new file mode 100644 index 00000000..48707b84 --- /dev/null +++ b/apps/web/app/manifest.ts @@ -0,0 +1,35 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Multica", + short_name: "Multica", + description: "Distributed AI agent framework", + id: "/", + scope: "/", + start_url: "/", + display: "standalone", + orientation: "any", + background_color: "#09090b", + theme_color: "#09090b", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/apps/web/app/sw-register.tsx b/apps/web/app/sw-register.tsx new file mode 100644 index 00000000..e49ec296 --- /dev/null +++ b/apps/web/app/sw-register.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; + +export function ServiceWorkerRegister() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/sw.js", { + scope: "/", + updateViaCache: "none", + }) + .catch((err) => { + if (process.env.NODE_ENV === "development") { + console.warn("SW registration failed:", err); + } + }); + } + }, []); + + return null; +} diff --git a/apps/web/app/~offline/page.tsx b/apps/web/app/~offline/page.tsx new file mode 100644 index 00000000..122a1eda --- /dev/null +++ b/apps/web/app/~offline/page.tsx @@ -0,0 +1,22 @@ +export default function OfflinePage() { + return ( +
+
+
*
+

+ You are offline +

+

+ Multica requires an internet connection. Please check your network and + try again. +

+
+
+ ); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 1e258c7f..7793796a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,21 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { transpilePackages: ["@multica/ui", "@multica/store"], + headers: async () => [ + { + source: "/sw.js", + headers: [ + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, + { + key: "Content-Type", + value: "application/javascript; charset=utf-8", + }, + ], + }, + ], }; export default nextConfig; diff --git a/apps/web/public/icon-192x192.png b/apps/web/public/icon-192x192.png new file mode 100644 index 00000000..5593b1e0 Binary files /dev/null and b/apps/web/public/icon-192x192.png differ diff --git a/apps/web/public/icon-512x512.png b/apps/web/public/icon-512x512.png new file mode 100644 index 00000000..be39f414 Binary files /dev/null and b/apps/web/public/icon-512x512.png differ diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 00000000..2d8efcb4 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,40 @@ +const CACHE_NAME = "multica-v1"; + +// App shell resources to precache +const PRECACHE_URLS = ["/~offline"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + // Clean up old caches + event.waitUntil( + caches.keys().then((names) => + Promise.all( + names + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + // Only handle navigation requests (HTML pages) + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request).catch(() => + caches.match("/~offline").then( + (response) => + response || + new Response("Offline", { status: 503, statusText: "Offline" }) + ) + ) + ); + } +});