diff --git a/chat.py b/chat.py
index 4a0874c..d0ac0b4 100644
--- a/chat.py
+++ b/chat.py
@@ -25,7 +25,7 @@ import html
import urllib.parse
PORT = 9999
-VERSION = "10"
+VERSION = "11"
TUNNEL_TARGETS = {
"parspack": ("185.208.174.152", 22),
"mequ": ("188.213.68.133", 2022),
@@ -86,17 +86,25 @@ CHAT_HTML = r"""
-
+
+
+
+
+
+
+
Chat
+
+ Install as app for notifications & fullscreen
+
+
+
+
-Shift+Enter for newline · Enter to send
-
+
-
-
+
"""
+PWA_MANIFEST = json.dumps({
+ "name": "Chat",
+ "short_name": "Chat",
+ "description": "Minimal multi-user chat",
+ "start_url": "/chat",
+ "display": "standalone",
+ "background_color": "#1a1a2e",
+ "theme_color": "#1a1a2e",
+ "icons": [
+ {"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
+ ]
+})
+
+# Minimal SVG icon (chat bubble)
+PWA_ICON = """"""
+
+SERVICE_WORKER = """
+const CACHE = 'chat-v1';
+self.addEventListener('install', e => { self.skipWaiting(); });
+self.addEventListener('activate', e => { e.waitUntil(clients.claim()); });
+self.addEventListener('fetch', e => {
+ // Let all requests go to network (chat is real-time, caching would break it)
+ // But cache the shell for offline "you're offline" experience
+ if (e.request.mode === 'navigate') {
+ e.respondWith(
+ fetch(e.request).catch(() => new Response(
+ 'Offline - connect to the internet
',
+ {headers: {'Content-Type': 'text/html'}}
+ ))
+ );
+ }
+});
+"""
+
# ── Multipart parser (minimal, for file uploads) ───────────────────────
def parse_multipart(body: bytes, boundary: str) -> dict:
@@ -455,6 +560,40 @@ async def handle_ws_tunnel(ws_reader, ws_writer, target):
async def handle_http(reader, writer, first_line):
method, path, headers, body = await parse_http_request(reader, first_line)
+ # PWA assets
+ if method == "GET" and path == "/manifest.json":
+ resp = PWA_MANIFEST.encode()
+ writer.write(b"HTTP/1.1 200 OK\r\n")
+ writer.write(b"Content-Type: application/manifest+json\r\n")
+ writer.write(f"Content-Length: {len(resp)}\r\n".encode())
+ writer.write(b"\r\n")
+ writer.write(resp)
+ await writer.drain()
+ writer.close()
+ return
+
+ if method == "GET" and path == "/icon.svg":
+ resp = PWA_ICON.encode()
+ writer.write(b"HTTP/1.1 200 OK\r\n")
+ writer.write(b"Content-Type: image/svg+xml\r\n")
+ writer.write(f"Content-Length: {len(resp)}\r\n".encode())
+ writer.write(b"\r\n")
+ writer.write(resp)
+ await writer.drain()
+ writer.close()
+ return
+
+ if method == "GET" and path == "/sw.js":
+ resp = SERVICE_WORKER.encode()
+ writer.write(b"HTTP/1.1 200 OK\r\n")
+ writer.write(b"Content-Type: application/javascript\r\n")
+ writer.write(f"Content-Length: {len(resp)}\r\n".encode())
+ writer.write(b"\r\n")
+ writer.write(resp)
+ await writer.drain()
+ writer.close()
+ return
+
# GET /version
if method == "GET" and path == "/version":
resp = json.dumps({"version": VERSION}).encode()