Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
206 lines
9.5 KiB
Python
206 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Serve packaged Gaia with a distinct loopback origin for every installed app."""
|
|
import argparse
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int, default=8765)
|
|
parser.add_argument("--legacy-apps", action="store_true")
|
|
args = parser.parse_args()
|
|
from project import build_info, validate_assets, release_info
|
|
|
|
validate_assets()
|
|
info = build_info()
|
|
info["release"] = release_info()
|
|
revision, commit_time = info["revision"], str(info["timestamp"])
|
|
(ROOT / "assets/build-info.json").write_text(json.dumps(info))
|
|
APPS = ROOT / "assets/webapps"
|
|
ORIGINS = {}
|
|
REGISTRY = []
|
|
pattern = re.compile(r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org")
|
|
|
|
|
|
def translate(text):
|
|
return pattern.sub(lambda m: f"http://{m[1]}.localhost:{args.port}", text)
|
|
|
|
|
|
manifests = sorted(APPS.glob("*/manifest.webapp"))
|
|
LEGACY_ROOTS = set()
|
|
if args.legacy_apps:
|
|
for entry in json.loads((ROOT / "tests/legacy-apps.json").read_text()):
|
|
directory = ROOT / "downloads/legacy-apps/apps" / entry["id"]
|
|
manifests.append(directory / "manifest.webapp")
|
|
LEGACY_ROOTS.add(directory.resolve())
|
|
for manifest_path in manifests:
|
|
name = manifest_path.parent.name.removesuffix(".gaiamobile.org")
|
|
if not re.fullmatch("[a-z0-9_-]+", name):
|
|
continue
|
|
origin = f"http://{name}.localhost:{args.port}"
|
|
manifest = json.loads(translate(manifest_path.read_text()))
|
|
ORIGINS[urlsplit(origin).netloc] = manifest_path.parent.resolve()
|
|
REGISTRY.append(
|
|
{
|
|
"id": name,
|
|
"origin": origin,
|
|
"manifestURL": origin + "/manifest.webapp",
|
|
"manifest": manifest,
|
|
}
|
|
)
|
|
ORIGINS[f"theme.localhost:{args.port}"] = ORIGINS[f"default_theme.localhost:{args.port}"]
|
|
(ROOT / "assets/registry.json").write_text(
|
|
json.dumps(REGISTRY, ensure_ascii=False, indent=2) + "\n"
|
|
)
|
|
(ROOT / "assets/settings-current-defaults.json").write_text(
|
|
translate((ROOT / "assets/settings-defaults.json").read_text())
|
|
)
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
root = ORIGINS.get(self.headers.get("Host", "").lower())
|
|
if root is None:
|
|
self.send_error(421, "Unregistered application host")
|
|
return
|
|
path = unquote(urlsplit(self.path).path)
|
|
if "\x00" in path or "\\" in path or ".." in Path(path).parts:
|
|
self.send_error(400)
|
|
return
|
|
if path == "/_vulpes/platform-config.js":
|
|
data = (('window.VulpesPlatform='+json.dumps('tundra' if os.environ.get('VULPES_TUNDRA')=='1' else 'desktop')+';window.VulpesNativeInput='+('true' if os.environ.get('VULPES_TUNDRA')=='1' or os.environ.get('VULPES_TEST_INPUT')=='1' else 'false')+';window.VulpesNativeTelephony=') + ('true' if os.environ.get('VULPES_TUNDRA_RADIO') == '1' or os.environ.get('VULPES_TEST_TELEPHONY') == '1' else 'false') + ';window.VulpesNativeWifi=' + ('true' if os.environ.get('VULPES_TUNDRA_WIFI') == '1' else 'false') + ';').encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/javascript")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return
|
|
if path == "/resources/gaia_commit.txt":
|
|
data = (revision + "\n" + commit_time + "\n").encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return
|
|
# Only this non-privileged compatibility layer is exposed, never host modules.
|
|
if path in [
|
|
"/_vulpes/" + name
|
|
for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]
|
|
]:
|
|
file = ROOT / "host" / path.rsplit("/", 1)[1]
|
|
else:
|
|
file = (root / path.lstrip("/")).resolve()
|
|
if file.is_dir():
|
|
file = file / "index.html"
|
|
if not file.is_relative_to(root):
|
|
self.send_error(403)
|
|
return
|
|
overrides = {
|
|
"system.gaiamobile.org": [
|
|
"js/launcher.js",
|
|
"js/gaia_scheduler.js",
|
|
"js/keyboard_manager.js",
|
|
"js/core.js",
|
|
"js/rocketbar.js",
|
|
]
|
|
}
|
|
if path.lstrip("/") in overrides.get(root.name, []):
|
|
file = (
|
|
ROOT
|
|
/ "gaia/apps"
|
|
/ root.name.removesuffix(".gaiamobile.org")
|
|
/ path.lstrip("/")
|
|
)
|
|
overlay_root = (ROOT / "overrides" / root.name).resolve()
|
|
overlay = (overlay_root / path.lstrip("/")).resolve()
|
|
if (
|
|
path != "/manifest.webapp"
|
|
and overlay.is_relative_to(overlay_root)
|
|
and overlay.is_file()
|
|
):
|
|
file = overlay
|
|
if path.startswith("/shared/"):
|
|
common = (ROOT / "overrides" / path.lstrip("/")).resolve()
|
|
if common.is_relative_to((ROOT / "overrides/shared").resolve()) and common.is_file():
|
|
file = common
|
|
if not file.is_file() and path.startswith("/shared/"):
|
|
shared = (ROOT / "gaia" / path.lstrip("/")).resolve()
|
|
if shared.is_relative_to((ROOT / "gaia/shared").resolve()):
|
|
file = shared
|
|
if not file.is_file():
|
|
self.send_error(404)
|
|
return
|
|
data = file.read_bytes()
|
|
mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
|
if file.suffix in (".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"):
|
|
text = translate(data.decode("utf-8"))
|
|
if file.suffix in (".css", ".js", ".html"):
|
|
text = (
|
|
text.replace("-moz-calc(", "calc(")
|
|
.replace("-moz-box-sizing:", "box-sizing:")
|
|
.replace("-moz-plaintext", "plaintext")
|
|
.replace(":-moz-dir(", ":dir(")
|
|
.replace("offset-inline-start:", "inset-inline-start:")
|
|
.replace("offset-inline-end:", "inset-inline-end:")
|
|
)
|
|
data = text.encode("utf-8")
|
|
if root.name == "music.gaiamobile.org" and file.suffix == ".js":
|
|
data = data.replace(b"audio.src=null;", b"audio.removeAttribute('src');")
|
|
if file.suffix == ".html" and root in LEGACY_ROOTS:
|
|
data = data.replace(
|
|
b"<head>",
|
|
b'<head><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script>',
|
|
1,
|
|
)
|
|
elif file.suffix == ".html" and not (root.name == "search.gaiamobile.org" and path.startswith("/home/")):
|
|
data = data.replace(
|
|
b"<head>",
|
|
b'<head><script src="/_vulpes/platform-config.js"></script><link rel="stylesheet" href="/_vulpes/desktop.css"><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>',
|
|
1,
|
|
)
|
|
if (
|
|
file.suffix == ".html"
|
|
and root.name == "communications.gaiamobile.org"
|
|
and "/dialer/" in path
|
|
):
|
|
data = data.replace(
|
|
b"<head>", b'<head><script src="/shared/js/accessibility_helper.js"></script>', 1
|
|
)
|
|
self.send_response(200)
|
|
self.send_header(
|
|
"Content-Type", mime + ("; charset=utf-8" if mime.startswith("text/") else "")
|
|
)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
# Installed Gaia code may use its legacy module compiler, but must not
|
|
# gain privileged host rights through inline or remote script injection.
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"script-src 'self' 'unsafe-eval'; object-src 'none'; "
|
|
"base-uri 'self'; worker-src 'self' blob:; "
|
|
f"frame-ancestors http://*.localhost:{args.port}",
|
|
)
|
|
origin = self.headers.get("Origin", "")
|
|
if urlsplit(origin).netloc in ORIGINS and origin.startswith("http://"):
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Vary", "Origin")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, fmt, *values):
|
|
if values and str(values[1]) not in ("200", "304"):
|
|
super().log_message(fmt, *values)
|
|
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
print(f"Gaia: {len(REGISTRY)} applications; 127.0.0.1:{args.port}", flush=True)
|
|
server.serve_forever()
|