feat: add PWA manifest and service worker

Add Progressive Web App support for installable mobile experience
on iOS Safari and Android Chrome.

- static/manifest.json: app name, standalone display, dark theme,
  icon references for 192px and 512px
- static/sw.js: service worker with cache-first strategy for static
  assets, network-first for HTML/HTMX, offline fallback to cache
- static/icon-192.png, static/icon-512.png: placeholder app icons
- Apple meta tags: apple-mobile-web-app-capable, status bar style,
  apple-touch-icon for iOS Add to Home Screen
- Service worker registration in base layout template

Closes leeworks-agents/gitea-mobile#6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-26 04:12:20 +00:00
parent 17ca1f6e6c
commit 712dc5632c
6 changed files with 122 additions and 0 deletions
+12
View File
@@ -97,6 +97,11 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title> <title>{{.Title}} — Gitea Mobile</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -162,6 +167,13 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
Settings Settings
</a> </a>
</nav> </nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body> </body>
</html>`)) </html>`))
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "Gitea Mobile",
"short_name": "Gitea",
"description": "Mobile-first PWA for managing Gitea issues and pull requests",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#161b22",
"orientation": "portrait",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+86
View File
@@ -0,0 +1,86 @@
// Service Worker for Gitea Mobile PWA
// Caches the app shell for offline/fast loading.
const CACHE_NAME = 'gitea-mobile-v1';
const APP_SHELL = [
'/',
'/static/style.css',
'/static/manifest.json',
'/static/icon-192.png',
'/static/icon-512.png',
'https://unpkg.com/htmx.org@1.9.10'
];
// Install: cache app shell resources.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(APP_SHELL);
})
);
// Activate immediately.
self.skipWaiting();
});
// Activate: clean up old caches.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// Take control of all clients immediately.
self.clients.claim();
});
// Fetch: network-first for API/HTML, cache-first for static assets.
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache-first for static assets.
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache the fetched response for next time.
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
return;
}
// Network-first for HTML/API requests.
if (event.request.headers.get('accept')?.includes('text/html') ||
event.request.headers.get('HX-Request')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful GET responses.
if (event.request.method === 'GET' && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache if network fails.
return caches.match(event.request);
})
);
return;
}
// Default: network only.
event.respondWith(fetch(event.request));
});