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
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"""<!DOCTYPE html>
<html lang="en">
<head>
<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>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace;
display: flex; flex-direction: column; height: 100vh; }
#messages { flex: 1; overflow-y: auto; padding: 12px; }
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; }
html, body { height: 100%; overflow: hidden; }
body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, 'Courier New', monospace;
display: flex; flex-direction: column; height: 100vh; height: 100dvh;
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 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 strong { color: #fff; }
.msg em { color: #ccc; }
@@ -104,35 +112,56 @@ CHAT_HTML = r"""<!DOCTYPE html>
.ts { color: #666; }
.sys { color: #5e9ca0; font-style: italic; }
.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; }
.file-link:hover { background: #1a4a80; }
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333;
.file-link:hover, .file-link:active { background: #1a4a80; }
#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; }
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; align-self: flex-end; }
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; resize: none; min-height: 38px;
max-height: 200px; font-family: inherit; font-size: inherit; line-height: 1.4; }
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff;
border-radius: 4px; cursor: pointer; align-self: flex-end; }
#send:hover { background: #c73e54; }
#file-btn { padding: 8px 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
border-radius: 4px; cursor: pointer; align-self: flex-end; font-size: 1.1em; }
#file-btn:hover { background: #1a4a80; }
#input { flex: 1; padding: 10px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 20px; resize: none; min-height: 40px;
max-height: 120px; font-family: inherit; font-size: 16px; line-height: 1.4; }
#send { padding: 10px 16px; background: #e94560; border: none; color: #fff;
border-radius: 20px; cursor: pointer; align-self: flex-end; font-size: 14px;
min-height: 40px; }
#send:hover, #send:active { background: #c73e54; }
#file-btn { padding: 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
border-radius: 50%; 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, #file-btn:active { background: #1a4a80; }
#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>
</head>
<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 class="hint">Shift+Enter for newline · Enter to send</div>
<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
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">Send</button>
<button id="send">&#9654;</button>
</div>
<script>
const $msg = document.getElementById('messages');
@@ -312,11 +341,87 @@ es.onmessage = function(e) {
notify(data);
};
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>
</body>
</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) ───────────────────────
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()