v11: PWA support for mobile - installable app with offline fallback

- Web manifest with standalone display mode
- SVG chat bubble icon (no external assets needed)
- Service worker for install + offline page
- iOS meta tags: apple-mobile-web-app-capable, status bar style
- Mobile-optimized layout: safe-area insets, dvh units, rounded inputs
- Name input moved to header, file button + send in bottom bar
- 16px font on input (prevents iOS zoom)
- Name persisted to localStorage on mobile
- Keyboard-aware scroll (visualViewport resize listener)
- Install banner with prompt for Android Chrome

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 15:14:38 +04:00
parent fe6ea164bf
commit 087334ffe9

191
chat.py
View File

@@ -25,7 +25,7 @@ import html
import urllib.parse import urllib.parse
PORT = 9999 PORT = 9999
VERSION = "10" VERSION = "11"
TUNNEL_TARGETS = { TUNNEL_TARGETS = {
"parspack": ("185.208.174.152", 22), "parspack": ("185.208.174.152", 22),
"mequ": ("188.213.68.133", 2022), "mequ": ("188.213.68.133", 2022),
@@ -86,17 +86,25 @@ CHAT_HTML = r"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,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="apple-mobile-web-app-title" content="Chat">
<meta name="theme-color" content="#1a1a2e">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.json">
<title>Chat</title> <title>Chat</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace; html, body { height: 100%; overflow: hidden; }
display: flex; flex-direction: column; height: 100vh; } body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, 'Courier New', monospace;
#messages { flex: 1; overflow-y: auto; padding: 12px; } display: flex; flex-direction: column; height: 100vh; height: 100dvh;
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; } padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
#messages { flex: 1; overflow-y: auto; padding: 10px; -webkit-overflow-scrolling: touch; }
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; font-size: 14px; }
.msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; } .msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; }
.msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px; .msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px;
padding: 8px; margin: 4px 0; overflow-x: auto; } padding: 8px; margin: 4px 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.msg pre code { background: none; padding: 0; color: #e0e0e0; } .msg pre code { background: none; padding: 0; color: #e0e0e0; }
.msg strong { color: #fff; } .msg strong { color: #fff; }
.msg em { color: #ccc; } .msg em { color: #ccc; }
@@ -104,35 +112,56 @@ CHAT_HTML = r"""<!DOCTYPE html>
.ts { color: #666; } .ts { color: #666; }
.sys { color: #5e9ca0; font-style: italic; } .sys { color: #5e9ca0; font-style: italic; }
.file-link { display: inline-block; background: #0f3460; border: 1px solid #444; .file-link { display: inline-block; background: #0f3460; border: 1px solid #444;
padding: 4px 10px; border-radius: 4px; margin: 2px 0; color: #67c7eb; padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
text-decoration: none; } text-decoration: none; }
.file-link:hover { background: #1a4a80; } .file-link:hover, .file-link:active { background: #1a4a80; }
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333; #header { display: flex; padding: 6px 10px; gap: 6px; background: #16213e;
border-bottom: 1px solid #333; align-items: center; }
#name { flex: 0 0 auto; width: 80px; padding: 8px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; font-size: 14px; }
#header-info { flex: 1; color: #555; font-size: 0.7em; text-align: right; }
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #333;
background: #16213e; align-items: flex-end; } background: #16213e; align-items: flex-end; }
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444; #input { flex: 1; padding: 10px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; align-self: flex-end; } color: #e0e0e0; border-radius: 20px; resize: none; min-height: 40px;
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444; max-height: 120px; font-family: inherit; font-size: 16px; line-height: 1.4; }
color: #e0e0e0; border-radius: 4px; resize: none; min-height: 38px; #send { padding: 10px 16px; background: #e94560; border: none; color: #fff;
max-height: 200px; font-family: inherit; font-size: inherit; line-height: 1.4; } border-radius: 20px; cursor: pointer; align-self: flex-end; font-size: 14px;
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff; min-height: 40px; }
border-radius: 4px; cursor: pointer; align-self: flex-end; } #send:hover, #send:active { background: #c73e54; }
#send:hover { background: #c73e54; } #file-btn { padding: 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
#file-btn { padding: 8px 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0; border-radius: 50%; cursor: pointer; align-self: flex-end; font-size: 1.1em;
border-radius: 4px; cursor: pointer; align-self: flex-end; font-size: 1.1em; } min-width: 40px; min-height: 40px; text-align: center; line-height: 20px; }
#file-btn:hover { background: #1a4a80; } #file-btn:hover, #file-btn:active { background: #1a4a80; }
#file-input { display: none; } #file-input { display: none; }
.hint { color: #555; font-size: 0.75em; padding: 2px 12px; } #install-bar { display: none; padding: 8px 12px; background: #0f3460; text-align: center;
border-bottom: 1px solid #333; }
#install-bar button { background: #e94560; border: none; color: #fff; padding: 6px 16px;
border-radius: 4px; cursor: pointer; margin: 0 4px; }
#install-bar .dismiss { background: transparent; color: #666; }
@media (max-width: 500px) {
.msg { font-size: 13px; }
.ts { font-size: 11px; }
#input { font-size: 16px; /* prevents iOS zoom */ }
}
</style> </style>
</head> </head>
<body> <body>
<div id="install-bar">
Install as app for notifications &amp; fullscreen
<button id="install-btn">Install</button>
<button class="dismiss" id="install-dismiss">Later</button>
</div>
<div id="header">
<input id="name" placeholder="Name" value="" autocomplete="off">
<span id="header-info">Shift+Enter newline</span>
</div>
<div id="messages"></div> <div id="messages"></div>
<div class="hint">Shift+Enter for newline · Enter to send</div>
<div id="bottom"> <div id="bottom">
<input id="name" placeholder="Name" value=""> <label id="file-btn" title="Upload file">&#128206;<input type="file" id="file-input"></label>
<textarea id="input" placeholder="Type a message…" rows="1" autofocus <textarea id="input" placeholder="Type a message…" rows="1" autofocus
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea> autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
<label id="file-btn" title="Upload file">&#128206;<input type="file" id="file-input"></label> <button id="send">&#9654;</button>
<button id="send">Send</button>
</div> </div>
<script> <script>
const $msg = document.getElementById('messages'); const $msg = document.getElementById('messages');
@@ -312,11 +341,87 @@ es.onmessage = function(e) {
notify(data); notify(data);
}; };
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); }; es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
// PWA install prompt
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
document.getElementById('install-bar').style.display = 'block';
});
document.getElementById('install-btn').onclick = function() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
}
document.getElementById('install-bar').style.display = 'none';
};
document.getElementById('install-dismiss').onclick = function() {
document.getElementById('install-bar').style.display = 'none';
};
// Service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
// Mobile keyboard handling: scroll to bottom when keyboard opens
if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
$msg.scrollTop = $msg.scrollHeight;
});
// Save name to localStorage
const savedName = localStorage.getItem('chat-name');
if (savedName) $name.value = savedName;
$name.addEventListener('change', function() {
localStorage.setItem('chat-name', this.value);
});
}
</script> </script>
</body> </body>
</html> </html>
""" """
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 = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="100" fill="#1a1a2e"/>
<path d="M128 140h256c22 0 40 18 40 40v152c0 22-18 40-40 40H210l-70 52v-52h-12c-22 0-40-18-40-40V180c0-22 18-40 40-40z" fill="#e94560"/>
<circle cx="200" cy="256" r="18" fill="#fff"/>
<circle cx="256" cy="256" r="18" fill="#fff"/>
<circle cx="312" cy="256" r="18" fill="#fff"/>
</svg>"""
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(
'<html><body style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh"><h2>Offline - connect to the internet</h2></body></html>',
{headers: {'Content-Type': 'text/html'}}
))
);
}
});
"""
# ── Multipart parser (minimal, for file uploads) ─────────────────────── # ── Multipart parser (minimal, for file uploads) ───────────────────────
def parse_multipart(body: bytes, boundary: str) -> dict: 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): async def handle_http(reader, writer, first_line):
method, path, headers, body = await parse_http_request(reader, 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 # GET /version
if method == "GET" and path == "/version": if method == "GET" and path == "/version":
resp = json.dumps({"version": VERSION}).encode() resp = json.dumps({"version": VERSION}).encode()