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:
191
chat.py
191
chat.py
@@ -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 & 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">📎<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">📎<input type="file" id="file-input"></label>
|
<button id="send">▶</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()
|
||||||
|
|||||||
Reference in New Issue
Block a user