#!/usr/bin/python3
# LawleyOS Portal - a fresh, self-contained WebKitGTK web browser.
# (Command, app ID and data folders keep the original lawleyos-browser name
# so existing launchers, default-browser settings and user data keep working.)
# Tabs, bookmarks, private mode, and privacy by default: tracker/ad blocking,
# HTTPS-only, no third-party cookies, tracking-link cleanup, Global Privacy
# Control, permission prompts, and one-click clearing of browsing data.
# Default search: DuckDuckGo.  Home: lawleyos.com.
import sys, os, json, re, html
import gi
gi.require_version("Gtk", "4.0"); gi.require_version("Adw", "1"); gi.require_version("WebKit", "6.0")
gi.require_version("Soup", "3.0")
from gi.repository import Gtk, Adw, WebKit, Gio, GLib, Gdk, Soup

HOME_URL   = "https://lawleyos.com"
SEARCH_URL = "https://duckduckgo.com/?q=%s"
APP_ID     = "com.lawleyos.Browser"

CFG_DIR    = os.path.join(GLib.get_user_config_dir(), "lawleyos-browser")
BOOKMARKS  = os.path.join(CFG_DIR, "bookmarks.json")
SETTINGS   = os.path.join(CFG_DIR, "settings.json")
# exists while browsing with "clear on close" on; if it's still there at
# startup, the last exit didn't finish clearing (crash/power loss)
CLEAR_MARK = os.path.join(CFG_DIR, ".clear-pending")

# system-wide encrypted DNS (DNS-over-TLS), applied by a root helper
DNS_HELPER = "/usr/sbin/lawleyos-dns"
DNS_CONF   = "/etc/systemd/resolved.conf.d/90-lawleyos-encrypted-dns.conf"
DNS_PROVIDERS = [("off", "Off (use your network's DNS)"), ("quad9", "Quad9"),
                 ("cloudflare", "Cloudflare"), ("mullvad", "Mullvad")]
DATA_DIR   = os.path.join(GLib.get_user_data_dir(), "lawleyos-browser")
CACHE_DIR  = os.path.join(GLib.get_user_cache_dir(), "lawleyos-browser")
FILTER_DIR = os.path.join(DATA_DIR, "filters")

# ---- built-in tracker / ad blocklist (WebKit content-blocker rules) ----
# grouped so the tracking report can say what kind of tracker was stopped
TRACKER_CATEGORIES = {
    "Data brokers": [
        "crwdcntrl.net", "demdex.net", "omtrdc.net", "everesttech.net", "krxd.net",
        "bluekai.com", "exelator.com", "agkn.com", "rlcdn.com", "tapad.com", "liadm.com",
        "id5-sync.com", "ad.gt", "hadronid.net", "anonymised.io", "permutive.app", "prmutv.co",
        "ml314.com", "euid.eu", "uidapi.com", "securedvisit.com", "optable.co", "idsequoia.ai",
        "turn.com", "adsrvr.org", "tru.am", "trackonomics.net", "impactradius-event.com",
        "stay22.com", "deeperdive.ai", "1rx.io", "dotomi.com", "creativecdn.com", "lijit.com",
        "ingage.tech", "zeropartyforms.com", "tq-tungsten.com",
    ],
    "Session recording": [
        "hotjar.com", "fullstory.com", "clarity.ms", "mouseflow.com", "crazyegg.com",
        "luckyorange.com", "inspectlet.com", "smartlook.com", "logrocket.com",
        "quantummetric.com", "contentsquare.net", "glassboxdigital.io", "sessioncam.com",
    ],
    "Advertising": [
        "doubleclick.net", "googlesyndication.com", "googletagservices.com",
        "adservice.google.com", "googleadservices.com", "ads.yahoo.com", "adnxs.com",
        "taboola.com", "outbrain.com", "criteo.com", "criteo.net", "moatads.com",
        "amazon-adsystem.com", "adsafeprotected.com", "doubleverify.com", "pubmatic.com",
        "rubiconproject.com", "openx.net", "casalemedia.com", "2mdn.net", "serving-sys.com",
        "bidswitch.net", "mathtag.com", "zedo.com", "adform.net", "adroll.com",
        "smartadserver.com", "yieldmo.com", "indexww.com", "sharethrough.com", "33across.com",
        "media.net", "teads.tv", "sonobi.com", "spotxchange.com", "contextweb.com",
        "adtrafficquality.google", "dv.tech", "zdbb.net", "ziffstatic.com", "ziff.tech",
        "3lift.com", "the-ozone-project.com", "yellowblue.io", "gumgum.com", "inmobi.com",
        "kueezrtb.com", "a-mo.net", "seedtag.com", "streamrail.com", "openxcdn.net",
        "servebom.com", "servenobid.com", "ad-delivery.net", "dns-finder.com", "pbxai.com",
        "adnami.io", "html-load.com", "nodals.io", "cpx.to", "webcontentassessor.com",
        "kargo.com", "fastclick.net", "btloader.com", "btmessage.com", "360yield.com",
        "panxo.ai", "panxostack.com", "panxo-sys.com", "trustedstack.com", "openwebmp.com",
        "optidigital.com", "opti-digital.com", "tappx.com", "bids.ws", "connatix.com",
        "ay.delivery", "emxdgt.com", "qovani.com", "colossusssp.com", "unrulymedia.com",
        "mediagotechnology.com", "pubgw.yahoo.com", "outcomes.net", "pix.pub", "tremorhub.com",
        "trustx.org", "fn-pz.com", "bagave.com", "cdnst.net", "advertising.amazon.dev",
        "publisher-services.amazon.dev", "adzerk.net", "kevel.co", "revcontent.com",
        "mgid.com", "zemanta.com", "undertone.com", "conversantmedia.com", "rhythmone.com",
        "vdx.tv", "admanmedia.com", "nativo.com", "triplelift.com",
    ],
    "Social media": [
        "connect.facebook.net", "facebook.com/tr", "px.ads.linkedin.com", "snap.licdn.com",
        "analytics.tiktok.com", "analytics.twitter.com", "ads-twitter.com", "ct.pinterest.com",
        "tr.snapchat.com", "bat.bing.com", "tiktokw.us", "rtb-us-west.linkedin.com",
        "alb.reddit.com", "pixel.reddit.com", "events.redditmedia.com", "braze.com",
        "appboycdn.com", "app-us1.com", "trackcmp.net", "bounceexchange.com", "bouncex.net",
        "p-n.io", "trueanthem.com", "viafoura.co", "klaviyo.com", "omnisend.com",
        "mailchimp.com/mc", "list-manage.com",
    ],
    "Analytics": [
        "google-analytics.com", "googletagmanager.com", "scorecardresearch.com",
        "quantserve.com", "quantcount.com", "imrworldwide.com", "chartbeat.com",
        "chartbeat.net", "mixpanel.com", "segment.io", "heapanalytics.com", "kissmetrics.io",
        "nr-data.net", "analytics.yahoo.com", "mc.yandex.ru", "stats.wp.com", "pixel.wp.com",
        "analytics.google.com", "segment.com", "mxpnl.com", "amplitude.com", "newrelic.com",
        "speedcurve.com", "rapidedge.io", "dotmetrics.net", "brandmetrics.com", "mrf.io",
        "newsroom.bi", "parsely.com", "knotch.com", "knotch.it", "knotch-cdn.com",
        "optimizely.com", "bmtrcs.com", "wikia-services.com", "cloudflareinsights.com",
        "plausible.io", "matomo.cloud", "hubspot.com/__ptq", "hs-analytics.net", "clicky.com",
        "statcounter.com", "sentry.io",
    ],
}
# red → blue: how much a category can learn about you
TRACKER_GRADES = {"Data brokers": "red", "Session recording": "red", "Advertising": "orange",
                  "Social media": "yellow", "Analytics": "blue"}
GRADE_TEXT = {"red": "Red — builds a profile of you across sites",
              "orange": "Orange — ad auctions, usually with an ID attached",
              "yellow": "Yellow — tells a social or marketing platform you were here",
              "blue": "Blue — basic visitor counting (acceptable, still blocked)"}
GRADE_ORDER = ["red", "orange", "yellow", "blue"]
BLOCK_DOMAINS = [d for ds in TRACKER_CATEGORIES.values() for d in ds]
# host -> [(path prefix or "", category)]
TRACKER_INDEX = {}
for _cat, _entries in TRACKER_CATEGORIES.items():
    for _d in _entries:
        _h, _, _p = _d.partition("/")
        TRACKER_INDEX.setdefault(_h, []).append(("/" + _p if _p else "", _cat))

def tracker_category(url):
    # -> (matched blocklist host, category) or None, using the same
    # host/subdomain + path matching as the content-blocker rules
    try: u = GLib.Uri.parse(url, GLib.UriFlags.NONE)
    except GLib.Error: return None
    host = (u.get_host() or "").lower()
    path = u.get_path() or "/"
    labels = host.split(".")
    for i in range(len(labels) - 1):          # a.b.example.com, b.example.com, example.com
        for prefix, cat in TRACKER_INDEX.get(".".join(labels[i:]), ()):
            if path.startswith(prefix):
                return ".".join(labels[i:]), cat
    return None

def _host_rx(host):
    return re.escape(host).replace(r"\-", "-")

def blocklist_json():
    # anchor each entry to the request's host (incl. subdomains) and only block
    # third-party loads, so e.g. a search for "hotjar.com" isn't itself blocked
    rules = []
    for d in BLOCK_DOMAINS:
        host, _, path = d.partition("/")
        pat = r"^[a-z]+://([^/:]+\.)?" + _host_rx(host)
        pat += "/" + re.escape(path) if path else "[:/]"
        rules.append({"trigger": {"url-filter": pat, "load-type": ["third-party"]},
                      "action": {"type": "block"}})
    # <a ping=...> / navigator.sendBeacon click tracking
    rules.append({"trigger": {"url-filter": ".*", "resource-type": ["ping"]},
                  "action": {"type": "block"}})
    return json.dumps(rules)

# ---- HTTPS-only: upgrade every http:// load, except local/LAN hosts and
# hosts the user explicitly allowed this session (from the warning page) ----
HTTP_LOCAL_RX = [
    r"^http://localhost[:/]", r"^http://[^/:]+\.localhost[:/]",
    # private/loopback IPv4 - whole addresses only, so a public name such as
    # 10.example.com or 127.0.0.1.sslip.io still gets upgraded
    r"^http://127\.[0-9]+\.[0-9]+\.[0-9]+[:/]", r"^http://10\.[0-9]+\.[0-9]+\.[0-9]+[:/]",
    r"^http://192\.168\.[0-9]+\.[0-9]+[:/]", r"^http://169\.254\.[0-9]+\.[0-9]+[:/]",
    r"^http://172\.1[6-9]\.[0-9]+\.[0-9]+[:/]", r"^http://172\.2[0-9]\.[0-9]+\.[0-9]+[:/]",
    r"^http://172\.3[01]\.[0-9]+\.[0-9]+[:/]",
    r"^http://\[",                           # IPv6 literal
    r"^http://[^/:.]+[:/]",                  # single-label host (router, nas)
    r"^http://[^/:]+\.local[:/]", r"^http://[^/:]+\.lan[:/]",
    r"^http://[^/:]+\.test[:/]", r"^http://[^/:]+\.home\.arpa[:/]",
]
def https_rules_json(allowed_hosts=()):
    rules = [{"trigger": {"url-filter": "^http://"}, "action": {"type": "make-https"}}]
    pats = HTTP_LOCAL_RX + [r"^http://" + _host_rx(h) + "[:/]" for h in sorted(allowed_hosts)]
    rules += [{"trigger": {"url-filter": p}, "action": {"type": "ignore-previous-rules"}}
              for p in pats]
    return json.dumps(rules)

# Injected into pages:
#  - all frames: Global Privacy Control signal (WebKitGTK can't add the header)
#  - top frame: strip tracking parameters from links before they're followed,
#    and from the current address (history only; the server already saw them),
#    telling the tracking report which ones it removed
TRACKING_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
                   "utm_id", "utm_name", "fbclid", "gclid", "dclid", "gbraid", "wbraid",
                   "msclkid", "yclid", "twclid", "ttclid", "igshid", "mc_cid", "mc_eid",
                   "_hsenc", "_hsmi", "mkt_tok", "li_fat_id", "oly_anon_id", "oly_enc_id",
                   "vero_id", "_openstat", "srsltid"]
GPC_JS = """try{Object.defineProperty(Navigator.prototype,'globalPrivacyControl',
{get:function(){return true},configurable:true});}catch(e){}"""
CLEAN_LINKS_JS = """(function(){
var P=new Set(%s);
function clean(href){try{var u=new URL(href,location.href);
 if(!/^https?:$/.test(u.protocol))return null;var hit=false;
 Array.from(u.searchParams.keys()).forEach(function(k){if(P.has(k.toLowerCase())){u.searchParams.delete(k);hit=true;
  try{window.webkit.messageHandlers.lawleyosTrack.postMessage({t:'param',k:k.toLowerCase()})}catch(e){}}});
 return hit?u.href:null;}catch(e){return null;}}
function onNav(ev){var a=ev.target&&ev.target.closest&&ev.target.closest('a[href]');
 if(!a)return;var c=clean(a.href);if(c)a.href=c;}
document.addEventListener('click',onNav,true);
document.addEventListener('auxclick',onNav,true);
var c=clean(location.href);if(c)try{history.replaceState(history.state,'',c);}catch(e){}
})();""" % json.dumps(TRACKING_PARAMS)

# Tracking report monitor (all frames, only while the report is switched on).
# Notes which outside addresses the page tries to load - blocked requests never
# reach the browser, so this is the only way to list them - plus common
# fingerprinting calls. It only observes; pages behave exactly as before.
TRACK_MONITOR_JS = r"""(function(){
if(window.__lawleyosTrack)return;window.__lawleyosTrack=1;
var H;try{H=window.webkit.messageHandlers.lawleyosTrack}catch(e){}if(!H)return;
var seen=new Set(),q=[],timer=0,fps={};
function post(m){try{H.postMessage(m)}catch(e){}}
function flush(){timer=0;if(q.length)post({t:'req',u:q.splice(0,200)});}
function note(u){try{u=new URL(String(u),location.href);
 if(!/^https?:$/.test(u.protocol))return;var k=u.origin+u.pathname;
 if(seen.has(k)||seen.size>3000)return;seen.add(k);q.push(u.href.slice(0,400));
 if(!timer)timer=setTimeout(flush,400);}catch(e){}}
function fp(k){if(fps[k])return;fps[k]=1;post({t:'fp',k:k});}
function scan(el){if(!el||el.nodeType!==1)return;var t=el.tagName;
 if(/^(SCRIPT|IMG|IFRAME|SOURCE|VIDEO|AUDIO|EMBED|INPUT)$/.test(t)){if(el.src)note(el.src);}
 else if(t==='LINK'&&el.href&&/stylesheet|preload|prefetch|preconnect|icon/i.test(el.rel||''))note(el.href);}
new MutationObserver(function(ms){ms.forEach(function(m){
 if(m.type==='attributes'){scan(m.target);return;}
 m.addedNodes.forEach(function(n){scan(n);
  if(n.querySelectorAll)n.querySelectorAll('script[src],img[src],iframe[src],link[href],source[src]').forEach(scan);});
});}).observe(document,{childList:true,subtree:true,attributes:true,attributeFilter:['src','href']});
var d=Object.getOwnPropertyDescriptor(HTMLImageElement.prototype,'src');
if(d&&d.set)Object.defineProperty(HTMLImageElement.prototype,'src',{configurable:true,
 enumerable:d.enumerable,get:d.get,set:function(v){note(v);return d.set.call(this,v);}});
var of=window.fetch;if(of)window.fetch=function(i){try{note(typeof i==='string'||i instanceof URL?i:i&&i.url)}catch(e){}
 return of.apply(window,arguments);};
var oo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){note(u);return oo.apply(this,arguments);};
var ob=navigator.sendBeacon;if(ob)navigator.sendBeacon=function(u){note(u);post({t:'beacon'});return ob.apply(navigator,arguments);};
document.addEventListener('click',function(e){var a=e.target&&e.target.closest&&e.target.closest('a[ping]');if(a)post({t:'beacon'});},true);
['toDataURL','toBlob'].forEach(function(n){var o=HTMLCanvasElement.prototype[n];
 if(o)HTMLCanvasElement.prototype[n]=function(){fp('canvas');return o.apply(this,arguments);};});
[window.WebGLRenderingContext,window.WebGL2RenderingContext].forEach(function(C){if(!C)return;
 var g=C.prototype.getParameter;C.prototype.getParameter=function(p){if(p===0x9245||p===0x9246)fp('webgl');return g.apply(this,arguments);};});
var OAC=window.OfflineAudioContext;if(OAC){var W=function(){fp('audio');return Reflect.construct(OAC,arguments,new.target||W);};
 W.prototype=OAC.prototype;window.OfflineAudioContext=W;}
})();"""

FINGERPRINT_LABELS = {
    "canvas": ("Canvas fingerprinting", "Drew hidden graphics and read them back, which can identify your device"),
    "webgl":  ("Graphics card details", "Asked for your exact graphics card model"),
    "audio":  ("Audio fingerprinting", "Processed hidden audio to measure your device"),
}

def strip_tracking(uri):
    # same cleanup for typed / opened URLs
    try:
        u = GLib.Uri.parse(uri, GLib.UriFlags.ENCODED)
        q = u.get_query()
        if not q or u.get_scheme() not in ("http", "https"): return uri
        keep = [p for p in q.split("&")
                if p.split("=", 1)[0].lower() not in TRACKING_PARAMS]
        if len(keep) == len(q.split("&")): return uri
        return GLib.Uri.build(GLib.UriFlags.ENCODED, u.get_scheme(), u.get_userinfo(),
                              u.get_host(), u.get_port(), u.get_path(),
                              "&".join(keep) or None, u.get_fragment()).to_string()
    except Exception:
        return uri

HTTP_FALLBACK_TAG = "lawleyos-allow-http"
HTTPS_WARN_PAGE = """<!doctype html><html data-theme="%(theme)s"><head><meta charset="utf-8"><title>Not secure</title>
<meta name="color-scheme" content="dark light">
<style>
 :root{--bg:#05070f;--fg:#e8f0ff;--warn:#ffd27a;--muted:#a9bbd3;--btn:#35c4e8;--btn-fg:#05070f;--go-bd:rgba(255,210,122,.5)}
 @media (prefers-color-scheme: light){
  :root{--bg:#f4f7fb;--fg:#13203a;--warn:#9a5800;--muted:#4a5d78;--btn:#0a9fc7;--btn-fg:#ffffff;--go-bd:rgba(154,88,0,.45)}}
 :root[data-theme="grey"]{--bg:#3f4247;--fg:#f1f3f6;--warn:#ffd27a;--muted:#d0d6de;--btn:#5fd3f0;--btn-fg:#1d2024;--go-bd:rgba(255,210,122,.55)}
 *{box-sizing:border-box;font-family:'Quicksand','Cantarell',sans-serif}
 html,body{margin:0;height:100%%;background:var(--bg);color:var(--fg)}
 .wrap{max-width:620px;margin:0 auto;height:100%%;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:24px}
 h1{font-size:26px;margin:0;color:var(--warn)} p{color:var(--muted);line-height:1.5;margin:0}
 code{color:var(--fg);word-break:break-all}
 .row{display:flex;gap:12px;margin-top:10px;flex-wrap:wrap}
 a{padding:10px 18px;border-radius:12px;text-decoration:none;font-weight:600;font-size:14px}
 .back{background:var(--btn);color:var(--btn-fg)} .go{border:1px solid var(--go-bd);color:var(--warn)}
</style></head><body><div class="wrap">
 <h1>No secure connection available</h1>
 <p><code>%(host)s</code> didn't respond over HTTPS, so LawleyOS Portal stopped
 instead of falling back to an unencrypted connection.</p>
 <p>If you continue, anyone on your network could see or change what you send and
 receive on this site. Don't enter passwords or personal details.</p>
 <div class="row"><a class="back" href="javascript:history.back()">Go back</a>
 <a class="go" href="%(http)s#""" + HTTP_FALLBACK_TAG + """">Continue to the insecure site</a></div>
</div></body></html>"""

START_PAGE = """<!doctype html><html data-theme="__THEME__"><head><meta charset="utf-8"><title>New Tab</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<style>
 /* dark is the default; the light set applies when the browser theme is Light
    (WebKit passes the browser's theme to pages as prefers-color-scheme) */
 :root{--cy:#35c4e8;--vi:#7a5af7;--bg:#05070f;--fg:#e8f0ff;--brand0:#dfe9ff;--tag:#7a93ad;
   --field:rgba(12,17,31,.85);--field-fg:#eaf2ff;--field-bd:rgba(53,196,232,.4);--glow:rgba(53,196,232,.14);
   --ring:rgba(53,196,232,.28);--tile:rgba(12,17,31,.7);--tile-fg:#cfe0f5;--tile-bd:rgba(120,150,220,.22);
   --tile-hover:#fff;--tile-glow:rgba(53,196,232,.35);--foot:#4a5b78;--head:#cfe0f5}
 @media (prefers-color-scheme: light){
  :root{--cy:#0a9fc7;--vi:#6a4cf0;--bg:#f4f7fb;--fg:#0f1a2b;--brand0:#22314f;--tag:#5a6f88;
   --field:#ffffff;--field-fg:#0f1a2b;--field-bd:rgba(10,126,164,.35);--glow:rgba(10,126,164,.10);
   --ring:rgba(10,159,199,.25);--tile:#ffffff;--tile-fg:#22314f;--tile-bd:rgba(30,60,120,.16);
   --tile-hover:#0a2540;--tile-glow:rgba(10,159,199,.22);--foot:#7d8ea6;--head:#22314f}}
 /* Grey: between the two; set by the browser (pages only know dark/light) */
 :root[data-theme="grey"]{--cy:#5fd3f0;--vi:#a08cff;--bg:#3f4247;--fg:#f1f3f6;--brand0:#ffffff;--tag:#c3cad4;
   --field:#4a4e54;--field-fg:#f4f6f9;--field-bd:rgba(95,211,240,.45);--glow:rgba(95,211,240,.12);
   --ring:rgba(95,211,240,.30);--tile:#4a4e54;--tile-fg:#e6eaf0;--tile-bd:rgba(255,255,255,.14);
   --tile-hover:#ffffff;--tile-glow:rgba(95,211,240,.30);--foot:#aeb6c1;--head:#e6eaf0}
 *{box-sizing:border-box;font-family:'Quicksand','Cantarell',sans-serif}
 html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);overflow:hidden}
 .wrap{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:26px}
 .brand{font-size:clamp(34px,6.5vw,52px);font-weight:800;letter-spacing:1px;white-space:nowrap;
   background:linear-gradient(90deg,var(--brand0),var(--cy) 55%,var(--vi));-webkit-background-clip:text;color:transparent}
 .tag{color:var(--tag);font-size:14px;letter-spacing:3px;margin-top:-16px}
 form{width:min(620px,86vw)}
 .search{width:100%;padding:16px 22px;border-radius:16px;border:1px solid var(--field-bd);
   background:var(--field);color:var(--field-fg);font-size:17px;outline:none;
   box-shadow:0 0 26px var(--glow)}
 .search:focus{border-color:var(--cy);box-shadow:0 0 0 3px var(--ring)}
 .tiles{display:flex;gap:14px;flex-wrap:wrap;justify-content:center;max-width:min(780px,92vw)}
 .tile{padding:12px 18px;border-radius:14px;border:1px solid var(--tile-bd);
   background:var(--tile);color:var(--tile-fg);text-decoration:none;font-size:14px;font-weight:600;transition:.15s}
 .tile:hover{border-color:var(--cy);color:var(--tile-hover);box-shadow:0 0 16px var(--tile-glow)}
 .foot{position:fixed;bottom:16px;display:flex;flex-direction:column;align-items:center;gap:8px;
   color:var(--foot);font-size:12px;letter-spacing:2px}
 .head{width:30px;height:30px;opacity:.85}
 .head svg{width:100%;height:100%;display:block}
 .head path{fill:var(--head)}
 .project{font-size:11px;letter-spacing:3px;margin-top:-4px}
 .site{font-size:11px;letter-spacing:1px;color:var(--foot);text-decoration:none}
 .site:hover{color:var(--cy);text-decoration:underline}
</style></head><body><div class="wrap">
 <div class="brand">LawleyOS Portal</div>
 <div class="tag">BUILT BY VISION &middot; DRIVEN BY FREEDOM</div>
 <form action="https://duckduckgo.com/" method="get" onsubmit="return go(this.q.value)">
   <input class="search" name="q" placeholder="Search DuckDuckGo or type a URL" autofocus>
 </form>
 <script>
 // same rules as the address bar: URL-looking input opens, anything else searches
 function go(v){
   var t=v.trim(), u=null;
   if(!t||/\\s/.test(t)) return true;
   if(/^(https?|about|file):/i.test(t)) u=t;
   else if(/^(localhost|\\[[0-9a-f:.]+\\]|(\\d{1,3}\\.){3}\\d{1,3})(:\\d+)?(\\/.*)?$/i.test(t)) u="http://"+t;
   else if(/^[\\w.-]+\\.[a-z]{2,}(:\\d+)?(\\/.*)?$/i.test(t)) u="https://"+t;
   if(u){location.href=u;return false;}
   return true;
 }
 </script>
 <div class="tiles">
   <a class="tile" href="https://www.encyclopedia.com/">Encyclopedia.com</a>
   <a class="tile" href="https://duckduckgo.com">DuckDuckGo</a>
   <a class="tile" href="https://en.wikipedia.org">Wikipedia</a>
   <a class="tile" href="https://www.sciencedirect.com/">ScienceDirect.com</a>
   <a class="tile" href="https://flathub.org">Flathub</a>
 </div>
 <div class="foot"><div class="head" title="LawleyOS"><!--LAWLEYOS-HEAD--></div>LAWLEYOS PORTAL<div class="project">PROJECT YELWAL</div><a class="site" href="https://lawleyos.com">Lawleyos.com</a></div>
</div></body></html>"""

# the LawleyOS head from the desktop's app-grid button (LawleyOS icon theme:
# view-app-grid-symbolic.svg), inlined so the start page loads nothing
LAWLEYOS_HEAD_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" focusable="false"> <g transform="translate(39.84,28.00) scale(0.207684)"> <g transform="translate(0.000000,963.000000) scale(0.100000,-0.100000)"> <path d="M3990 9555 c0 -13 -33 -15 -240 -15 -207 0 -240 -2 -240 -15 0 -12 -18 -15 -105 -15 -87 0 -105 -3 -105 -15 0 -12 -17 -15 -90 -15 -73 0 -90 -3 -90 -15 0 -12 -15 -15 -75 -15 -60 0 -75 -3 -75 -15 0 -12 -13 -15 -60 -15 -47 0 -60 -3 -60 -15 0 -12 -13 -15 -60 -15 -47 0 -60 -3 -60 -15 0 -11 -12 -15 -45 -15 -33 0 -45 -4 -45 -15 0 -11 -12 -15 -45 -15 -33 0 -45 -4 -45 -15 0 -11 -12 -15 -45 -15 -33 0 -45 -4 -45 -15 0 -11 -12 -15 -45 -15 -33 0 -45 -4 -45 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -11 -12 -15 -45 -15 -33 0 -45 -4 -45 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -11 0 -15 -12 -15 -45 0 -33 -4 -45 -15 -45 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -11 0 -15 -12 -15 -45 0 -33 -4 -45 -15 -45 -10 0 -15 -10 -15 -30 0 -20 -5 -30 -15 -30 -11 0 -15 -12 -15 -45 0 -33 -4 -45 -15 -45 -11 0 -15 -12 -15 -45 0 -33 -4 -45 -15 -45 -12 0 -15 -13 -15 -60 0 -47 -3 -60 -15 -60 -12 0 -15 -13 -15 -60 0 -47 -3 -60 -15 -60 -12 0 -15 -15 -15 -75 0 -60 -3 -75 -15 -75 -12 0 -15 -17 -15 -90 0 -73 -3 -90 -15 -90 -13 0 -15 -27 -15 -180 0 -153 -2 -180 -15 -180 -12 0 -15 -20 -15 -120 0 -100 3 -120 15 -120 13 0 15 -23 15 -150 0 -127 2 -150 15 -150 12 0 15 -17 15 -90 0 -73 3 -90 15 -90 12 0 15 -15 15 -75 0 -60 3 -75 15 -75 12 0 15 -13 15 -60 0 -47 3 -60 15 -60 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 12 0 15 -13 15 -60 0 -47 3 -60 15 -60 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 11 0 15 -12 15 -45 0 -33 4 -45 15 -45 12 0 15 -15 15 -75 0 -60 3 -75 15 -75 13 0 15 -37 15 -270 l0 -270 60 0 c47 0 60 -3 60 -15 0 -13 22 -15 135 -15 113 0 135 -2 135 -15 0 -12 17 -15 90 -15 73 0 90 -3 90 -15 0 -12 17 -15 90 -15 73 0 90 -3 90 -15 0 -12 15 -15 75 -15 60 0 75 -3 75 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -12 15 -15 75 -15 60 0 75 -3 75 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 27 0 30 3 30 30 0 27 -3 30 -30 30 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -13 0 -15 65 -15 525 l0 525 30 0 c20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 12 0 15 -13 15 -60 0 -47 3 -60 15 -60 13 0 15 -42 15 -315 0 -273 2 -315 15 -315 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 11 0 15 12 15 45 0 33 4 45 15 45 13 0 15 22 15 135 0 113 2 135 15 135 12 0 15 13 15 60 0 47 3 60 15 60 12 0 15 13 15 60 0 47 3 60 15 60 10 0 15 10 15 30 0 27 -3 30 -30 30 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -13 0 -15 112 -15 945 0 833 -2 945 -15 945 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 13 -58 15 -465 15 l-465 0 0 30 c0 20 5 30 15 30 8 0 15 7 15 15 0 8 7 15 15 15 10 0 15 10 15 30 0 20 5 30 15 30 10 0 15 10 15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -11 0 -15 12 -15 45 l0 45 540 0 c473 0 540 -2 540 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 10 0 15 10 15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 27 -3 30 -30 30 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -13 0 -15 42 -15 315 0 273 2 315 15 315 8 0 15 -7 15 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 l30 0 0 -120 c0 -100 3 -120 15 -120 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -27 3 -30 30 -30 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -27 3 -30 30 -30 27 0 30 -3 30 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -27 3 -30 30 -30 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 10 0 15 -10 15 -30 0 -20 -5 -30 -15 -30 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -10 -10 -15 -30 -15 -20 0 -30 -5 -30 -15 0 -8 -7 -15 -15 -15 -13 0 -15 -108 -15 -915 0 -807 2 -915 15 -915 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -10 10 -15 30 -15 20 0 30 -5 30 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -11 12 -15 45 -15 33 0 45 -4 45 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -12 13 -15 60 -15 47 0 60 -3 60 -15 0 -12 17 -15 90 -15 73 0 90 -3 90 -15 0 -12 18 -15 105 -15 87 0 105 -3 105 -15 0 -13 22 -15 135 -15 113 0 135 2 135 15 0 12 15 15 75 15 60 0 75 3 75 15 0 11 12 15 45 15 33 0 45 4 45 15 0 10 10 15 30 15 20 0 30 5 30 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 10 0 15 10 15 30 0 20 5 30 15 30 10 0 15 10 15 30 0 20 5 30 15 30 11 0 15 12 15 45 0 33 4 45 15 45 12 0 15 18 15 105 0 87 -3 105 -15 105 -12 0 -15 15 -15 75 0 60 -3 75 -15 75 -12 0 -15 15 -15 75 0 60 -3 75 -15 75 -8 0 -15 7 -15 15 0 8 7 15 15 15 12 0 15 13 15 60 0 47 3 60 15 60 10 0 15 10 15 30 0 20 5 30 15 30 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 10 10 15 30 15 20 0 30 5 30 15 0 8 7 15 15 15 12 0 15 17 15 90 0 73 -3 90 -15 90 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 5 30 15 30 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 10 10 15 30 15 20 0 30 5 30 15 0 8 7 15 15 15 11 0 15 12 15 45 0 33 4 45 15 45 11 0 15 12 15 45 0 33 -4 45 -15 45 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -12 0 -15 18 -15 105 0 87 3 105 15 105 10 0 15 10 15 30 0 20 5 30 15 30 8 0 15 7 15 15 0 8 7 15 15 15 8 0 15 7 15 15 0 10 10 15 30 15 20 0 30 5 30 15 0 11 12 15 45 15 33 0 45 4 45 15 0 10 10 15 30 15 20 0 30 5 30 15 0 11 12 15 45 15 33 0 45 4 45 15 0 8 7 15 15 15 8 0 15 7 15 15 0 10 10 15 30 15 20 0 30 5 30 15 0 8 7 15 15 15 8 0 15 7 15 15 0 8 7 15 15 15 10 0 15 10 15 30 0 20 5 30 15 30 12 0 15 18 15 105 0 87 -3 105 -15 105 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -12 0 -15 15 -15 75 0 60 -3 75 -15 75 -12 0 -15 15 -15 75 0 60 3 75 15 75 12 0 15 17 15 90 0 73 3 90 15 90 13 0 15 53 15 420 0 367 -2 420 -15 420 -12 0 -15 18 -15 105 0 87 -3 105 -15 105 -12 0 -15 13 -15 60 0 47 -3 60 -15 60 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -12 0 -15 13 -15 60 0 47 -3 60 -15 60 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -11 0 -15 12 -15 45 0 33 -4 45 -15 45 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -10 0 -15 10 -15 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 10 -10 15 -30 15 -20 0 -30 5 -30 15 0 11 -12 15 -45 15 -33 0 -45 4 -45 15 0 11 -12 15 -45 15 -33 0 -45 4 -45 15 0 11 -12 15 -45 15 -33 0 -45 4 -45 15 0 12 -13 15 -60 15 -47 0 -60 3 -60 15 0 12 -15 15 -75 15 -60 0 -75 3 -75 15 0 12 -15 15 -75 15 -60 0 -75 3 -75 15 0 13 -23 15 -150 15 -127 0 -150 2 -150 15 0 13 -23 15 -150 15 -127 0 -150 -2 -150 -15 0 -8 -7 -15 -15 -15 -13 0 -15 -132 -15 -1125 0 -993 2 -1125 15 -1125 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 10 0 15 -10 15 -30 0 -20 5 -30 15 -30 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 8 0 15 -7 15 -15 0 -8 7 -15 15 -15 12 0 15 -13 15 -60 0 -47 -3 -60 -15 -60 -11 0 -15 -12 -15 -45 0 -33 -4 -45 -15 -45 -8 0 -15 -7 -15 -15 0 -8 -7 -15 -15 -15 -8 0 -15 -7 -15 -15 0 -12 -18 -15 -105 -15 -87 0 -105 3 -105 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -13 0 -15 142 -15 1215 0 1073 -2 1215 -15 1215 -8 0 -15 7 -15 15 0 8 -7 15 -15 15 -8 0 -15 -7 -15 -15z"/> </g> </g> </svg>"""
START_PAGE = START_PAGE.replace("<!--LAWLEYOS-HEAD-->", LAWLEYOS_HEAD_SVG)

CSS = """
.lawleyos-url { border-radius: 18px; padding: 6px 14px; }
headerbar.private { box-shadow: inset 0 -2px 0 0 #7a5af7; }
.lawleyos-pill { border-radius: 999px; padding: 2px 12px 2px 10px; min-height: 26px; }
.track-heading { font-weight: 700; margin: 12px 4px 6px 4px; }
.grade-dot { font-size: 14px; margin-right: 6px; }
.grade-red { color: #e01e37; } .grade-orange { color: #f28c28; }
.grade-yellow { color: #d4a800; } .grade-blue { color: #1b7fd6; }
.grade-legend { font-size: 11px; margin: 2px 4px 0 4px; }
.private-badge { font-weight: 700; font-size: 11px; border-radius: 10px; padding: 1px 8px; }
"""
# accent colours per scheme (GTK 4.14 CSS has no prefers-color-scheme, so the
# matching block is swapped in whenever the theme changes)
CSS_DARK = """
.lawleyos-pill.active { color: #35c4e8; background: rgba(53,196,232,.14); }
.lawleyos-pill.active:hover { background: rgba(53,196,232,.24); }
.private-badge { color: #b9a6ff; border: 1px solid rgba(122,90,247,.5); }
"""
CSS_LIGHT = """
.lawleyos-pill.active { color: #0a7ea4; background: rgba(10,126,164,.12); }
.lawleyos-pill.active:hover { background: rgba(10,126,164,.20); }
.private-badge { color: #5b3fd6; border: 1px solid rgba(91,63,214,.45); }
"""
# Grey: libadwaita's dark style (light text) with its surfaces recoloured to
# mid greys, between the Dark and Light themes
CSS_GREY = CSS_DARK + """
@define-color window_bg_color #45484e;
@define-color window_fg_color #f1f3f6;
@define-color view_bg_color #3f4247;
@define-color view_fg_color #f1f3f6;
@define-color headerbar_bg_color #52565d;
@define-color headerbar_fg_color #f4f6f9;
@define-color headerbar_backdrop_color #4b4f55;
@define-color popover_bg_color #52565d;
@define-color popover_fg_color #f4f6f9;
@define-color dialog_bg_color #4b4f55;
@define-color dialog_fg_color #f4f6f9;
@define-color card_bg_color rgba(255,255,255,.08);
@define-color accent_color #5fd3f0;
.lawleyos-pill.active { color: #5fd3f0; background: rgba(95,211,240,.16); }
"""
THEMES = [("light", "Light", "weather-clear-symbolic"),
          ("dark", "Dark", "weather-clear-night-symbolic"),
          ("grey", "Grey", "weather-overcast-symbolic"),
          ("system", "Match system", "emblem-system-symbolic")]

def _write_json(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as fh: json.dump(data, fh, indent=2)
    os.replace(tmp, path)

def _new_report():
    # per-tab, in memory only; reset on every page load
    return {"blocked": {}, "third": {}, "fp": set(), "params": set(),
            "beacons": 0, "perms": []}

def _base_domain(host):
    # registrable domain (bbc.co.uk for news.bbc.co.uk); IPs/localhost as-is
    if not host: return ""
    try: return Soup.tld_get_base_domain(host)
    except Exception: return host

def _dns_provider():
    # read the helper's config directly (world-readable; no root needed)
    try:
        with open(DNS_CONF) as fh:
            for line in fh:
                if line.startswith("# provider: "):
                    return line.split(":", 1)[1].strip()
    except OSError:
        pass
    return "off"

def _uri_host(uri):
    try: return (GLib.Uri.parse(uri, GLib.UriFlags.NONE).get_host() or "").lower()
    except Exception: return ""

def looks_like_url(text):
    t = text.strip()
    if not t or " " in t:
        return False
    if t.startswith(("http://", "https://", "about:", "file://")):
        return True
    if is_local_host(t):
        return True
    # a.b with no spaces -> treat as host
    return bool(re.match(r"^[\w.-]+\.[a-z]{2,}(:\d+)?(/.*)?$", t, re.I))

def is_local_host(t):
    # localhost, bare IPv4, or bracketed IPv6 (optionally with port/path)
    return bool(re.match(r"^(localhost|\[[0-9a-f:.]+\]|(\d{1,3}\.){3}\d{1,3})(:\d+)?(/.*)?$",
                         t, re.I))

def to_uri(text):
    t = text.strip()
    if looks_like_url(t):
        if t.startswith(("http://", "https://", "about:", "file://")):
            return strip_tracking(t)
        # dev servers and router pages are usually plain http
        return strip_tracking(("http://" if is_local_host(t) else "https://") + t)
    return SEARCH_URL % GLib.uri_escape_string(t, None, True)


class Browser(Adw.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID,
                         flags=Gio.ApplicationFlags.HANDLES_OPEN)
        self.session = None
        self.filters = {}            # content-filter id -> compiled filter
        self._filters_pending = {"lawleyos-blocklist", "lawleyos-https"}
        self._after_filters = []     # page loads waiting for the filters
        self.filter_store = None
        self.http_allowed = set()    # hosts allowed over plain http, this session only
        self.bookmarks = []
        self.settings = {"clear_on_exit": False, "tracking_report": True, "theme": "light"}
        self._failed_dls = set()

    # ---------- lifecycle ----------
    def do_startup(self):
        Adw.Application.do_startup(self)
        for d in (CFG_DIR, DATA_DIR, CACHE_DIR, FILTER_DIR):
            os.makedirs(d, mode=0o700, exist_ok=True)
        # cookies (live website logins), bookmarks and cache: owner only
        for d in (CFG_DIR, DATA_DIR, CACHE_DIR):
            try: os.chmod(d, 0o700)
            except OSError: pass
        self._load_bookmarks()
        self._load_settings()

        prov = Gtk.CssProvider(); prov.load_from_string(CSS)
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), prov, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

        # colour scheme: Light by default, Dark, Grey, or follow the system.
        # Web pages follow it too (prefers-color-scheme), incl. the start page.
        self._scheme_css = Gtk.CssProvider()
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), self._scheme_css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        sm = Adw.StyleManager.get_default()
        sm.connect("notify::dark", lambda *_: self._load_scheme_css())
        if self.settings.get("theme") not in [t[0] for t in THEMES]:
            self.settings["theme"] = "light"
        theme_act = Gio.SimpleAction.new_stateful(
            "theme", GLib.VariantType.new("s"), GLib.Variant.new_string(self.settings["theme"]))
        theme_act.connect("activate", lambda a, v: self.set_theme(v.get_string()))
        self.add_action(theme_act)
        self._apply_theme()

        # persistent session + favicons + downloads
        self.session = WebKit.NetworkSession.new(DATA_DIR, CACHE_DIR)
        try: self.session.get_website_data_manager().set_favicons_enabled(True)
        except Exception: pass
        # WebKitGTK keeps cookies in memory only unless given a file, which
        # signed users out of every site on restart (private windows stay in memory)
        self.session.get_cookie_manager().set_persistent_storage(
            os.path.join(DATA_DIR, "cookies.sqlite"), WebKit.CookiePersistentStorage.SQLITE)
        self.harden_session(self.session)
        if self.settings["clear_on_exit"]:
            if os.path.exists(CLEAR_MARK):
                self._clear_blocking()      # last exit didn't get to clear
            self._set_clear_mark(True)

        # compile the content filters (async): ad/tracker blocklist + HTTPS-only
        self.filter_store = WebKit.UserContentFilterStore.new(FILTER_DIR)
        self._compile_filter("lawleyos-blocklist", blocklist_json())
        self._compile_filter("lawleyos-https", https_rules_json(self.http_allowed))

    # ---------- theme ----------
    def set_theme(self, theme):
        if theme not in [t[0] for t in THEMES]: return
        self.lookup_action("theme").set_state(GLib.Variant.new_string(theme))
        self.set_setting("theme", theme)
        self._apply_theme()
        for win in self.get_windows():
            if hasattr(win, "sync_theme_pill"): win.sync_theme_pill()
            # the browser's own pages (new tab, warning) switch without a reload
            for wv in getattr(win, "webviews", lambda: [])():
                if win.is_own_page(wv):
                    wv.evaluate_javascript(
                        f"document.documentElement.dataset.theme={json.dumps(self.page_theme())}",
                        -1, None, None, None, None, None)

    def page_theme(self):
        # data-theme for the browser's own pages; dark/light come from
        # prefers-color-scheme, which pages already get
        return "grey" if self.settings["theme"] == "grey" else ""

    def _apply_theme(self):
        Adw.StyleManager.get_default().set_color_scheme({
            "dark": Adw.ColorScheme.FORCE_DARK,
            "light": Adw.ColorScheme.FORCE_LIGHT,
            "grey": Adw.ColorScheme.FORCE_DARK,     # light text on grey
            "system": Adw.ColorScheme.DEFAULT}[self.settings["theme"]])
        self._load_scheme_css()

    def _load_scheme_css(self):
        if self.settings["theme"] == "grey":
            css = CSS_GREY
        else:
            css = CSS_DARK if Adw.StyleManager.get_default().get_dark() else CSS_LIGHT
        self._scheme_css.load_from_string(css)

    def harden_session(self, session):
        # Intelligent Tracking Prevention (off by default in WebKitGTK),
        # no third-party cookies, and never load pages with bad certificates
        session.set_itp_enabled(True)
        session.get_cookie_manager().set_accept_policy(
            WebKit.CookieAcceptPolicy.NO_THIRD_PARTY)
        session.set_tls_errors_policy(WebKit.TLSErrorsPolicy.FAIL)
        self.wire_downloads(session)

    def do_shutdown(self):
        if self.settings["clear_on_exit"] and self.session:
            if self._clear_blocking():
                self._set_clear_mark(False)
        Adw.Application.do_shutdown(self)

    def _clear_blocking(self, timeout_s=8):
        # used at startup/exit, when there's no main loop to come back to
        loop, ok = GLib.MainLoop(), []
        self.clear_browsing_data(self.session, lambda: (ok.append(True), loop.quit()))
        GLib.timeout_add_seconds(timeout_s, loop.quit)
        loop.run()
        return bool(ok)

    def _set_clear_mark(self, on):
        try:
            if on: open(CLEAR_MARK, "w").close()
            elif os.path.exists(CLEAR_MARK): os.remove(CLEAR_MARK)
        except OSError: pass

    # ---------- settings ----------
    def _load_settings(self):
        try:
            with open(SETTINGS) as fh: self.settings.update(json.load(fh))
        except Exception: pass
    def set_setting(self, key, value):
        self.settings[key] = value
        try: _write_json(SETTINGS, self.settings)
        except OSError: pass
        if key == "clear_on_exit":
            self._set_clear_mark(value)
        if key == "tracking_report":
            # update open tabs (takes effect for what they load next) and windows
            for ucm in self._all_ucms():
                ucm.remove_all_scripts(); self._add_scripts(ucm)
            for win in self.get_windows():
                if hasattr(win, "sync_pill"): win.sync_pill()

    def do_activate(self):
        self._new_window()

    def do_open(self, files, n, hint):
        # links opened from other apps: a tab in an existing normal window,
        # or a new window holding just those tabs (no extra blank tab)
        wins = [w for w in self.get_windows() if isinstance(w, BrowserWindow) and not w.private]
        active = self.get_active_window()
        win = active if active in wins else (wins[0] if wins else None)
        if win is None:
            win = BrowserWindow(self)
        for f in files:
            win.new_tab(f.get_uri())
        win.present()

    # ---------- content filters ----------
    def _compile_filter(self, fid, rules, then=None):
        def done(store, res):
            try:
                f = store.save_finish(res)
            except Exception as e:
                print(f"lawleyos-browser: content filter {fid} disabled: {e}", file=sys.stderr)
                f = None
            if f:
                self.filters[fid] = f
                # (re)apply to every open tab
                for ucm in self._all_ucms():
                    ucm.remove_filter_by_id(fid)
                    ucm.add_filter(f)
            self._filters_pending.discard(fid)
            if not self._filters_pending:
                waiting, self._after_filters = self._after_filters, []
                for fn in waiting: fn()
            if then and f: then()
        self.filter_store.save(fid, GLib.Bytes.new(rules.encode()), None, done)

    def when_filters_ready(self, fn):
        # run fn now, or once the blocklist and HTTPS-only rules are compiled
        # (~0.2 s at startup) so a page opened at launch is protected too
        if self._filters_pending: self._after_filters.append(fn)
        else: fn()

    def _all_ucms(self):
        for win in self.get_windows():
            tv = getattr(win, "tabview", None)
            if not tv: continue
            for i in range(tv.get_n_pages()):
                yield tv.get_nth_page(i).get_child().get_user_content_manager()

    def allow_http(self, host, then):
        # user chose "Continue to the insecure site" on the HTTPS-only page
        self.http_allowed.add(host.lower())
        self._compile_filter("lawleyos-https", https_rules_json(self.http_allowed), then)

    # ---------- clear browsing data ----------
    def clear_browsing_data(self, session, done=None):
        mgr = session.get_website_data_manager()
        def cleared(m, res):
            try: m.clear_finish(res)
            except Exception as e:
                print(f"lawleyos-browser: clearing data failed: {e}", file=sys.stderr)
                return
            try: m.get_favicon_database().clear()
            except Exception: pass
            if done: done()
        mgr.clear(WebKit.WebsiteDataTypes.ALL, 0, None, cleared)

    # ---------- downloads ----------
    def wire_downloads(self, session):
        session.connect("download-started", self._on_download_started)

    def _on_download_started(self, session, download):
        download.connect("decide-destination", self._dl_decide)
        download.connect("finished", self._dl_finished)
        download.connect("failed", self._dl_failed)

    def notify(self, title, body, icon):
        # The standard notification service: GNOME rejects GApplication
        # notifications from this app ("app com.lawleyos.Browser could not be
        # found") because its launcher is named lawleyos-browser.desktop.
        conn = self.get_dbus_connection()
        if not conn: return
        conn.call("org.freedesktop.Notifications", "/org/freedesktop/Notifications",
                  "org.freedesktop.Notifications", "Notify",
                  GLib.Variant("(susssasa{sv}i)", ("LawleyOS Portal", 0, icon, title, body, [],
                               {"desktop-entry": GLib.Variant("s", "lawleyos-browser")}, -1)),
                  None, Gio.DBusCallFlags.NONE, -1, None, None)

    def _download_window(self, download):
        # the window whose tab started the download (else the active one)
        wv = download.get_web_view()
        for win in self.get_windows():
            if isinstance(win, BrowserWindow) and wv in win.webviews():
                return win
        win = self.get_active_window()
        return win if isinstance(win, BrowserWindow) else None

    def _dl_decide(self, download, suggested):
        base = os.path.basename(suggested or "download") or "download"
        ddir = (GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD)
                or os.path.join(GLib.get_home_dir(), "Downloads"))
        try: os.makedirs(ddir, exist_ok=True)
        except Exception: pass
        root, ext = os.path.splitext(base)
        path, i = os.path.join(ddir, base), 1
        while os.path.exists(path):
            path = os.path.join(ddir, f"{root} ({i}){ext}"); i += 1
        download.set_destination(path)
        win = self._download_window(download)
        if win: win.toast(f"Downloading {os.path.basename(path)}…")
        return True

    def _dl_failed(self, download, error):
        self._failed_dls.add(download)
        # a cancelled download (e.g. user aborted) isn't worth a notification
        if error.matches(WebKit.download_error_quark(), WebKit.DownloadError.CANCELLED_BY_USER):
            return
        name = os.path.basename(download.get_destination() or "") or "file"
        self.notify("Download failed", f"{name}: {error.message}", "dialog-error-symbolic")
        win = self._download_window(download)
        if win: win.toast(f"Download failed: {name}")

    def _dl_finished(self, download):
        # "finished" is also emitted after "failed"; don't report success then
        if download in self._failed_dls:
            self._failed_dls.discard(download)
            return
        path = download.get_destination() or ""
        name = os.path.basename(path) or "file"
        self.notify("Download finished", name, "folder-download-symbolic")
        win = self._download_window(download)
        if win: win.toast(f"Saved {name} to Downloads", "Show", lambda: win.show_file(path))

    # ---------- bookmarks ----------
    def _load_bookmarks(self):
        try:
            with open(BOOKMARKS) as fh: self.bookmarks = json.load(fh)
        except Exception:
            self.bookmarks = []
    def _save_bookmarks(self):
        try: _write_json(BOOKMARKS, self.bookmarks)
        except OSError: pass
    def is_bookmarked(self, url):
        return any(b.get("url") == url for b in self.bookmarks)
    def toggle_bookmark(self, url, title):
        if not url or url.startswith("about:"): return
        if self.is_bookmarked(url):
            self.bookmarks = [b for b in self.bookmarks if b.get("url") != url]
        else:
            self.bookmarks.append({"title": title or url, "url": url})
        self._save_bookmarks()

    # ---------- windows ----------
    def _new_window(self, private=False):
        win = BrowserWindow(self, private=private)
        win.present()
        win.new_tab(None)     # start page
        return win

    def new_private_window(self):
        self._new_window(private=True)

    def _add_scripts(self, ucm):
        ucm.add_script(WebKit.UserScript.new(
            GPC_JS, WebKit.UserContentInjectedFrames.ALL_FRAMES,
            WebKit.UserScriptInjectionTime.START, None, None))
        ucm.add_script(WebKit.UserScript.new(
            CLEAN_LINKS_JS, WebKit.UserContentInjectedFrames.TOP_FRAME,
            WebKit.UserScriptInjectionTime.END, None, None))
        if self.settings["tracking_report"]:
            ucm.add_script(WebKit.UserScript.new(
                TRACK_MONITOR_JS, WebKit.UserContentInjectedFrames.ALL_FRAMES,
                WebKit.UserScriptInjectionTime.START, None, None))

    def make_webview(self, session=None, related=None):
        ucm = WebKit.UserContentManager()
        for f in self.filters.values():   # not yet compiled -> added when ready
            ucm.add_filter(f)
        ucm.register_script_message_handler("lawleyosTrack", None)
        self._add_scripts(ucm)
        if related is not None:
            # popup (window.open / target=_blank): must share the opener's
            # web process + session, so build it as a related view
            wv = WebKit.WebView(related_view=related, user_content_manager=ucm)
        else:
            wv = WebKit.WebView(network_session=session or self.session,
                                user_content_manager=ucm)
        s = wv.get_settings()
        s.set_enable_developer_extras(True)
        s.set_javascript_can_open_windows_automatically(False)
        # pages may not read the clipboard behind your back (Ctrl+V still works)
        s.set_javascript_can_access_clipboard(False)
        s.set_enable_webrtc(False)          # no local-IP leaks via WebRTC
        s.set_enable_encrypted_media(False) # no DRM modules / device IDs
        try: s.set_media_playback_requires_user_gesture(True)
        except Exception: pass
        return wv


class BrowserWindow(Adw.ApplicationWindow):
    def __init__(self, app, private=False):
        super().__init__(application=app)
        self.app = app
        self.private = private
        # ONE session per window: private tabs share cookies/logins with each
        # other (previously every private tab got its own blank session)
        if private:
            self.net_session = WebKit.NetworkSession.new_ephemeral()
            app.harden_session(self.net_session)
        else:
            self.net_session = app.session
        self.set_default_size(1180, 820)
        self.set_title("LawleyOS Portal" + (" (Private)" if private else ""))

        tv = self.toolbar = Adw.ToolbarView()
        self.header = Adw.HeaderBar()
        if private: self.header.add_css_class("private")

        # nav buttons
        nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2)
        self.b_back = self._icon_btn("go-previous-symbolic", self.on_back)
        self.b_fwd  = self._icon_btn("go-next-symbolic", self.on_forward)
        self.b_rel  = self._icon_btn("view-refresh-symbolic", self.on_reload)
        self.b_home = self._icon_btn("go-home-symbolic", self.on_home)
        for b in (self.b_back, self.b_fwd, self.b_rel, self.b_home): nav.append(b)
        self.header.pack_start(nav)

        # tracking report pill: shield + number of trackers blocked on this page
        pill_box = Gtk.Box(spacing=6)
        pill_box.append(Gtk.Image(icon_name="security-high-symbolic"))
        self.pill_lbl = Gtk.Label(label="0")
        pill_box.append(self.pill_lbl)
        self.pill = Gtk.MenuButton(child=pill_box, tooltip_text="Tracking report")
        self.pill.add_css_class("lawleyos-pill")
        self.pill_pop = Gtk.Popover()
        # permanent container: swapping the popover's own child while it opens
        # upsets GTK's focus tracking
        self.report_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.pill_pop.set_child(self.report_box)
        self.pill.set_popover(self.pill_pop)
        self.pill.connect("notify::active",
                          lambda b, _p: self._rebuild_report() if b.get_active() else None)
        self.header.pack_start(self.pill)

        # address entry
        self.url = Gtk.Entry(hexpand=True); self.url.add_css_class("lawleyos-url")
        self.url.set_placeholder_text("Search DuckDuckGo or type a URL")
        self.url.connect("activate", self.on_url_activate)
        pa = Gtk.EntryIconPosition.SECONDARY
        self.url.set_icon_from_icon_name(pa, "starred-symbolic")
        self.url.set_icon_tooltip_text(pa, "Bookmark this page")
        self.url.connect("icon-press", lambda e, p: self.on_star())
        self.header.set_title_widget(self.url)

        # right side: new tab, bookmarks, menu
        self._new_tab_btn = self._icon_btn("tab-new-symbolic", lambda *_: self.new_tab(None))
        self.header.pack_end(self._menu_button())
        self.header.pack_end(self._theme_button())
        self.header.pack_end(self._bookmarks_button())
        self.header.pack_end(self._new_tab_btn)
        if private:
            badge = Gtk.Label(label="PRIVATE"); badge.add_css_class("private-badge")
            self.header.pack_end(badge)

        tv.add_top_bar(self.header)

        # tabs
        self.tabview = Adw.TabView(vexpand=True)
        self.tabview.connect("notify::selected-page", self._on_tab_switched)
        self.tabview.connect("close-page", self._on_close_page)
        tabbar = Adw.TabBar(view=self.tabview)
        tv.add_top_bar(tabbar)

        # slim load-progress bar under the tab bar (attached this time)
        self.progress = Gtk.ProgressBar(); self.progress.add_css_class("osd")
        self.progress.set_visible(False)
        tv.add_top_bar(self.progress)

        self.toasts = Adw.ToastOverlay(child=self.tabview)
        tv.set_content(self.toasts)
        self.set_content(tv)

        # keyboard shortcuts
        self._add_shortcuts()

    # ---- helpers ----
    def _icon_btn(self, icon, cb):
        b = Gtk.Button(icon_name=icon); b.connect("clicked", lambda *_: cb())
        return b

    def _menu_button(self):
        menu = Gio.Menu()
        menu.append("New Tab", "win.new-tab")
        menu.append("New Private Window", "win.new-private")
        s1 = Gio.Menu()
        s1.append("Clear Browsing Data…", "win.clear-data")
        s1.append("Privacy Settings…", "win.privacy")
        menu.append_section(None, s1)
        s2 = Gio.Menu()
        s2.append("Zoom In", "win.zoom-in")
        s2.append("Zoom Out", "win.zoom-out")
        s2.append("Reset Zoom", "win.zoom-reset")
        menu.append_section(None, s2)
        s3 = Gio.Menu()
        s3.append("About LawleyOS Portal", "win.about")
        menu.append_section(None, s3)
        for name, fn in [("new-tab", lambda *_: self.new_tab(None)),
                         ("new-private", lambda *_: self.app.new_private_window()),
                         ("zoom-in", lambda *_: self._zoom(0.1)),
                         ("zoom-out", lambda *_: self._zoom(-0.1)),
                         ("zoom-reset", lambda *_: self._zoom(0)),
                         ("clear-data", lambda *_: self._confirm_clear_data()),
                         ("privacy", lambda *_: self._privacy_settings()),
                         ("about", lambda *_: self._about())]:
            act = Gio.SimpleAction.new(name, None); act.connect("activate", fn)
            self.add_action(act)
        mb = Gtk.MenuButton(icon_name="open-menu-symbolic"); mb.set_menu_model(menu)
        return mb

    def _theme_button(self):
        # pill with a drop-down: Dark / Light / Match system (app-wide)
        menu = Gio.Menu()
        for tid, name, _icon in THEMES:
            menu.append(name, f"app.theme::{tid}")
        box = Gtk.Box(spacing=6)
        self.theme_icon = Gtk.Image()
        self.theme_lbl = Gtk.Label()
        box.append(self.theme_icon); box.append(self.theme_lbl)
        box.append(Gtk.Image(icon_name="pan-down-symbolic"))
        self.theme_btn = Gtk.MenuButton(child=box, menu_model=menu, tooltip_text="Colour theme")
        self.theme_btn.add_css_class("lawleyos-pill")
        self.sync_theme_pill()
        return self.theme_btn

    def sync_theme_pill(self):
        tid = self.app.settings["theme"]
        _t, name, icon = next(t for t in THEMES if t[0] == tid)
        self.theme_icon.set_from_icon_name(icon)
        self.theme_lbl.set_label("System" if tid == "system" else name)

    def _bookmarks_button(self):
        self.bm_btn = Gtk.MenuButton(icon_name="user-bookmarks-symbolic")
        self.bm_pop = Gtk.Popover()
        self.bm_btn.set_popover(self.bm_pop)
        self.bm_btn.connect("notify::active", lambda *_: self._rebuild_bookmarks() if self.bm_btn.get_active() else None)
        return self.bm_btn

    def _rebuild_bookmarks(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.set_size_request(280, -1)
        hdr = Gtk.Label(label="Bookmarks", xalign=0); hdr.add_css_class("heading")
        hdr.set_margin_start(8); hdr.set_margin_top(6); hdr.set_margin_bottom(4)
        box.append(hdr)
        if not self.app.bookmarks:
            e = Gtk.Label(label="No bookmarks yet — click the star."); e.add_css_class("dim-label")
            e.set_margin_start(8); e.set_margin_top(6); e.set_margin_bottom(8); e.set_wrap(True)
            box.append(e)
        else:
            lb = Gtk.ListBox(); lb.add_css_class("boxed-list")
            for b in self.app.bookmarks:
                # plain text: titles/URLs often contain "&", which isn't valid markup
                row = Adw.ActionRow(use_markup=False)
                row.set_title(b.get("title") or b.get("url")); row.set_subtitle(b.get("url"))
                row.set_activatable(True)
                row.connect("activated", lambda r, u=b.get("url"): (self.new_tab(u), self.bm_pop.popdown()))
                lb.append(row)
            box.append(lb)
        self.bm_pop.set_child(box)

    def _add_shortcuts(self):
        sc = {"<Control>t": lambda *_: self.new_tab(None),
              "<Control>w": lambda *_: self._close_current(),
              "<Control>l": lambda *_: self.url.grab_focus(),
              "<Control>r": lambda *_: self.on_reload(),
              "<Control>d": lambda *_: self.on_star(),
              "<Control>equal": lambda *_: self._zoom(0.1),
              "<Control>minus": lambda *_: self._zoom(-0.1),
              "<Control>0":     lambda *_: self._zoom(0),
              "F5":          lambda *_: self.on_reload(),
              "<Alt>Left":   lambda *_: self.on_back(),
              "<Alt>Right":  lambda *_: self.on_forward(),
              "<Control><Shift>Delete": lambda *_: self._confirm_clear_data()}
        ctl = Gtk.ShortcutController(); ctl.set_scope(Gtk.ShortcutScope.GLOBAL)
        for accel, fn in sc.items():
            act = Gtk.CallbackAction.new(lambda w, a, f=fn: (f() or True))
            ctl.add_shortcut(Gtk.Shortcut.new(Gtk.ShortcutTrigger.parse_string(accel), act))
        self.add_controller(ctl)

    # ---- tabs / webviews ----
    def new_tab(self, url, switch=True, webview=None):
        wv = webview or self.app.make_webview(session=self.net_session)
        wv.track = _new_report()          # before append: that selects the tab
        page = self.tabview.append(wv)
        page.set_title("New Tab")
        wv.connect("notify::title", lambda w, _p, pg=page: pg.set_title(w.get_title() or "New Tab"))
        wv.connect("notify::favicon", lambda w, _p, pg=page: self._set_tab_icon(w, pg))
        wv.connect("load-changed", self._on_load_changed, page)
        wv.connect("notify::estimated-load-progress", self._on_progress)
        wv.connect("create", self._on_create)
        wv.connect("close", self._on_webview_close)        # window.close()
        wv.connect("enter-fullscreen", self._on_fullscreen, True)
        wv.connect("leave-fullscreen", self._on_fullscreen, False)
        wv.connect("decide-policy", self._on_decide_policy)
        wv.connect("load-failed", self._on_load_failed)
        wv.connect("load-failed-with-tls-errors", self._on_tls_failed)
        wv.connect("permission-request", self._on_permission)
        wv.connect("resource-load-started", self._on_resource)
        wv.get_user_content_manager().connect(
            "script-message-received::lawleyosTrack", self._on_track_msg, wv)
        wv.http_attempts = {}     # host -> http:// URI the page/user asked for
        wv.https_warn_host = None
        if webview is None:
            # (popup children load their own target after `create` returns)
            if url:
                self.app.when_filters_ready(lambda: wv.load_uri(url))
            else:
                # about:blank origin: the start page must not pose as lawleyos.com
                wv.load_html(START_PAGE.replace("__THEME__", self.app.page_theme()), "about:blank")
        if switch:
            self.tabview.set_selected_page(page)
        return wv

    def _set_tab_icon(self, wv, page):
        page.set_icon(wv.get_favicon())       # None clears the previous site's icon

    def toast(self, title, button=None, on_button=None):
        t = Adw.Toast(use_markup=False)        # file names may contain "&" or "<"
        t.set_title(title)
        if button:
            t.set_button_label(button)
            t.connect("button-clicked", lambda *_: on_button())
        self.toasts.add_toast(t)

    def show_file(self, path):
        # open the containing folder with the file selected
        Gtk.FileLauncher.new(Gio.File.new_for_path(path)).open_containing_folder(self, None, None, None)

    def webviews(self):
        return [self.tabview.get_nth_page(i).get_child() for i in range(self.tabview.get_n_pages())]

    def is_own_page(self, wv):
        # the new-tab page, or the HTTPS warning currently shown for a site
        uri = wv.get_uri() or ""
        return uri == "about:blank" or (
            wv.https_warn_host is not None and _uri_host(uri) == wv.https_warn_host
            and wv.get_title() == "Not secure")

    def current_webview(self):
        p = self.tabview.get_selected_page()
        return p.get_child() if p else None

    def _close_current(self):
        p = self.tabview.get_selected_page()
        if p: self.tabview.close_page(p)

    def _on_close_page(self, view, page):
        view.close_page_finish(page, True)
        GLib.idle_add(self._check_empty)
        return True

    def _check_empty(self):
        if self.tabview.get_n_pages() == 0:
            self.close()
        return False

    def _on_webview_close(self, wv):
        # pages (e.g. "Sign in with..." pop-ups) closing themselves
        page = self.tabview.get_page(wv)
        if page: self.tabview.close_page(page)

    def _on_fullscreen(self, wv, entering):
        # video fullscreen: hide the toolbar/tabs; WebKit fullscreens the window
        self.toolbar.set_reveal_top_bars(not entering)
        return False

    def _on_create(self, wv, nav_action):
        # target=_blank / window.open -> open in a new tab; the popup view must
        # be RELATED to its opener (shared web process + session)
        child = self.app.make_webview(related=wv)
        self.new_tab(None, switch=True, webview=child)
        return child

    # ---- HTTPS-only ----
    def _on_decide_policy(self, wv, decision, dtype):
        if dtype == WebKit.PolicyDecisionType.RESPONSE:
            # WebKitGTK silently drops pages it can't display unless told to
            # download them (zips, installers, "Download" buttons...)
            if decision.is_main_frame_main_resource():
                headers = decision.get_response().get_http_headers()
                disp = (headers.get_one("Content-Disposition") or "") if headers else ""
                if (not decision.is_mime_type_supported() or
                        disp.strip().lower().startswith("attachment")):
                    decision.download()
                    return True
            return False
        if dtype not in (WebKit.PolicyDecisionType.NAVIGATION_ACTION,
                         WebKit.PolicyDecisionType.NEW_WINDOW_ACTION):
            return False
        nav = decision.get_navigation_action()
        if (dtype == WebKit.PolicyDecisionType.NAVIGATION_ACTION
                and nav.get_navigation_type() == WebKit.NavigationType.LINK_CLICKED
                and (nav.get_mouse_button() == 2
                     or nav.get_modifiers() & Gdk.ModifierType.CONTROL_MASK)):
            # middle-click / Ctrl+click: open in a new background tab
            decision.ignore()
            self.new_tab(nav.get_request().get_uri(), switch=False)
            return True
        uri = nav.get_request().get_uri() or ""
        if not uri.startswith("http://"):
            return False
        host = _uri_host(uri)
        if uri.endswith("#" + HTTP_FALLBACK_TAG):
            # only honoured from our own warning page for that same host
            target = uri[:-len(HTTP_FALLBACK_TAG) - 1]
            decision.ignore()
            if host and host == wv.https_warn_host and _uri_host(wv.get_uri() or "") == host:
                wv.https_warn_host = None
                self.app.allow_http(host, lambda: wv.load_uri(target))
            return True
        if host:
            wv.http_attempts[host] = uri
        return False

    def _https_unavailable(self, wv, failing_uri):
        host = _uri_host(failing_uri)
        if not failing_uri.startswith("https://") or host not in wv.http_attempts:
            return False
        http_uri = "http://" + failing_uri[len("https://"):]
        wv.https_warn_host = host
        wv.load_alternate_html(HTTPS_WARN_PAGE % {"host": html.escape(host),
                                                  "http": html.escape(http_uri, quote=True),
                                                  "theme": self.app.page_theme()},
                               failing_uri, None)
        return True

    def _on_load_failed(self, wv, event, failing_uri, error):
        if (error.matches(WebKit.network_error_quark(), WebKit.NetworkError.CANCELLED) or
                error.matches(WebKit.policy_error_quark(),
                              WebKit.PolicyError.FRAME_LOAD_INTERRUPTED_BY_POLICY_CHANGE)):
            return False
        return self._https_unavailable(wv, failing_uri or "")

    def _on_tls_failed(self, wv, failing_uri, cert, errors):
        return self._https_unavailable(wv, failing_uri or "")

    # ---- site permissions: always ask, never remembered ----
    def _on_permission(self, wv, req):
        what = None
        if isinstance(req, WebKit.GeolocationPermissionRequest):
            what = "know your location"
        elif isinstance(req, WebKit.UserMediaPermissionRequest):
            cam = WebKit.user_media_permission_is_for_video_device(req)
            mic = WebKit.user_media_permission_is_for_audio_device(req)
            what = ("use your camera and microphone" if cam and mic else
                    "use your camera" if cam else "use your microphone")
            if WebKit.user_media_permission_is_for_display_device(req):
                what = "share your screen"
        elif isinstance(req, WebKit.NotificationPermissionRequest):
            what = "show notifications"
        elif isinstance(req, WebKit.ClipboardPermissionRequest):
            what = "read your clipboard"
        elif isinstance(req, WebKit.WebsiteDataAccessPermissionRequest):
            what = f"use cookies on {req.get_current_domain()}"
        elif isinstance(req, WebKit.PointerLockPermissionRequest):
            req.allow(); return True         # games; released with Esc
        if what is None:                     # device enumeration, DRM, XR, ...
            self._note_perm(wv, "see your cameras and microphones"
                            if isinstance(req, WebKit.DeviceInfoPermissionRequest) else
                            "use DRM / device identifiers"
                            if isinstance(req, WebKit.MediaKeySystemPermissionRequest) else
                            "use a restricted browser feature", "blocked automatically")
            req.deny(); return True
        host = _uri_host(wv.get_uri() or "") or "This page"
        d = Adw.AlertDialog(heading=f"{host} wants to {what}",
                            body="Your choice applies only this time.")
        d.add_response("deny", "Block"); d.add_response("allow", "Allow")
        d.set_default_response("deny"); d.set_close_response("deny")
        def answered(_d, r):
            self._note_perm(wv, what, "you allowed" if r == "allow" else "you blocked")
            req.allow() if r == "allow" else req.deny()
        d.connect("response", answered)
        d.present(self)
        return True

    # ---- tracking report ----
    def _reporting(self):
        return self.app.settings["tracking_report"]

    def _on_resource(self, wv, resource, request):
        # outside sites the page actually loaded from (allowed requests)
        if not self._reporting(): return
        page_base = _base_domain(_uri_host(wv.get_uri() or ""))
        uri = request.get_uri() or ""
        if not uri.startswith(("http://", "https://")) or uri == wv.get_uri(): return
        base = _base_domain(_uri_host(uri))
        if base and page_base and base != page_base and len(wv.track["third"]) < 200:
            wv.track["third"][base] = wv.track["third"].get(base, 0) + 1
            self._report_changed(wv)

    def _on_track_msg(self, ucm, value, wv):
        if not self._reporting(): return
        try: msg = json.loads(value.to_json(0))
        except Exception: return
        if not isinstance(msg, dict): return
        t, tr = msg.get("t"), wv.track
        page_base = _base_domain(_uri_host(wv.get_uri() or ""))
        if t == "req" and isinstance(msg.get("u"), list):
            for u in msg["u"][:200]:
                if not isinstance(u, str): continue
                hit = tracker_category(u)
                # the blocklist only applies to third-party loads
                if hit and _base_domain(_uri_host(u)) != page_base and len(tr["blocked"]) < 200:
                    e = tr["blocked"].setdefault(hit[0], {"cat": hit[1], "n": 0})
                    e["n"] += 1
        elif t == "fp" and msg.get("k") in FINGERPRINT_LABELS:
            tr["fp"].add(msg["k"])
        elif t == "param" and msg.get("k") in TRACKING_PARAMS:
            tr["params"].add(msg["k"])
        elif t == "beacon":
            tr["beacons"] += 1
        else:
            return
        self._report_changed(wv)

    def _note_perm(self, wv, what, outcome):
        if self._reporting() and len(wv.track["perms"]) < 20:
            wv.track["perms"].append((what, outcome))
            self._report_changed(wv)

    def _report_changed(self, wv):
        if wv is self.current_webview():
            self.sync_pill()
            if self.pill.get_active(): self._schedule_rebuild()

    def _schedule_rebuild(self):
        # coalesce bursts of updates while the report is open
        if not getattr(self, "_rebuild_src", 0):
            def run():
                self._rebuild_src = 0
                if self.pill.get_active(): self._rebuild_report()
                return False
            self._rebuild_src = GLib.timeout_add(300, run)

    def sync_pill(self):
        on = self._reporting()
        self.pill.set_visible(on)
        wv = self.current_webview()
        if not on or not wv: return
        n = sum(e["n"] for e in wv.track["blocked"].values())
        self.pill_lbl.set_label(str(n))
        if n: self.pill.add_css_class("active")
        else: self.pill.remove_css_class("active")
        self.pill.set_tooltip_text(f"Tracking report: {n} tracker request{'s' if n != 1 else ''} blocked")

    def _rebuild_report(self):
        wv = self.current_webview()
        tr = wv.track if wv else _new_report()
        host = _uri_host(wv.get_uri() or "") if wv else ""

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.set_size_request(420, -1)
        title = Gtk.Label(label="Tracking report", xalign=0, css_classes=["title-4"])
        title.set_margin_start(4); title.set_margin_top(4)
        outer.append(title)
        sub = Gtk.Label(label=host or "New tab", xalign=0, css_classes=["dim-label"],
                        ellipsize=3)
        sub.set_margin_start(4)
        outer.append(sub)

        body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        def section(heading, rows):
            if not rows: return
            body.append(Gtk.Label(label=heading, xalign=0, css_classes=["track-heading"]))
            lb = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE, css_classes=["boxed-list"])
            for row in rows:
                t, st, suffix = row[:3]
                grade = row[3] if len(row) > 3 else None
                r = Adw.ActionRow(use_markup=False)   # page-supplied text: never markup
                r.set_title(t); r.set_subtitle(st or "")
                if grade:
                    r.add_prefix(Gtk.Label(label="●", css_classes=["grade-dot", "grade-" + grade],
                                           tooltip_text=GRADE_TEXT[grade]))
                if suffix:
                    r.add_suffix(Gtk.Label(label=suffix, css_classes=["dim-label"]))
                lb.append(r)
            body.append(lb)

        # worst grade first (red), then by how often the tracker was blocked
        blocked = sorted(tr["blocked"].items(),
                         key=lambda kv: (GRADE_ORDER.index(TRACKER_GRADES.get(kv[1]["cat"], "blue")),
                                         -kv[1]["n"], kv[0]))
        if blocked:
            worst = TRACKER_GRADES.get(blocked[0][1]["cat"], "blue")
            counts = {}
            for _d, e in blocked:
                g = TRACKER_GRADES.get(e["cat"], "blue"); counts[g] = counts.get(g, 0) + 1
            summary = "  ".join(f"● {counts[g]} {g}" for g in GRADE_ORDER if g in counts)
            lg = Gtk.Label(label=summary, xalign=0, css_classes=["grade-legend", "grade-" + worst],
                           tooltip_text="\n".join(GRADE_TEXT[g] for g in GRADE_ORDER))
            lg.set_margin_top(6)
            body.append(lg)
        section(f"Blocked trackers ({len(blocked)})",
                [(d, e["cat"], f"×{e['n']}", TRACKER_GRADES.get(e["cat"], "blue")) for d, e in blocked])
        section("Fingerprinting attempts",
                [FINGERPRINT_LABELS[k] + ("",) for k in sorted(tr["fp"])])
        if tr["params"]:
            section("Tracking links cleaned",
                    [("Removed tracking tags from links", ", ".join(sorted(tr["params"])), "")])
        if tr["beacons"]:
            section("Background beacons",
                    [("Sent background reports (e.g. clicks, leaving the page)",
                      "Any going to known trackers were blocked", f"×{tr['beacons']}")])
        section("Permissions requested", [(w[0].capitalize(), w[1].capitalize(), "")
                                          for w in tr["perms"]])
        third = sorted(tr["third"].items(), key=lambda kv: (-kv[1], kv[0]))[:40]
        section(f"Other outside sites contacted ({len(tr['third'])})",
                [(d, "Allowed — not a known tracker", f"×{n}") for d, n in third])

        if not body.get_first_child():
            empty = Adw.StatusPage(icon_name="security-high-symbolic",
                                   title="Nothing to report",
                                   description="No trackers or fingerprinting found on this page yet.")
            empty.add_css_class("compact")
            body.append(empty)

        foot = Gtk.Label(xalign=0, wrap=True, css_classes=["dim-label", "caption"],
            label="This site was also told not to sell or share your data (Global Privacy "
                  "Control). Turn this report off in Privacy Settings.")
        foot.set_margin_top(12); foot.set_margin_start(4); foot.set_margin_end(4)
        body.append(foot)

        sc = Gtk.ScrolledWindow(child=body, propagate_natural_height=True,
                                max_content_height=640, hscrollbar_policy=Gtk.PolicyType.NEVER,
                                vscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
        sc.set_margin_top(6)
        outer.append(sc)
        old = self.report_box.get_first_child()
        if old: self.report_box.remove(old)
        self.report_box.append(outer)

    # ---- clear browsing data ----
    def _confirm_clear_data(self):
        d = Adw.AlertDialog(heading="Clear browsing data?",
            body=("Deletes cookies, site storage, cache and saved site logins for this "
                  + ("private window." if self.private else
                     "browser. You'll be signed out of websites. Bookmarks are kept.")))
        d.add_response("cancel", "Cancel"); d.add_response("clear", "Clear Data")
        d.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
        d.set_default_response("cancel"); d.set_close_response("cancel")
        def resp(_d, r):
            if r == "clear":
                self.app.clear_browsing_data(self.net_session,
                    lambda: self.toasts.add_toast(Adw.Toast(title="Browsing data cleared")))
        d.connect("response", resp)
        d.present(self)

    # ---- privacy settings ----
    def _privacy_settings(self):
        dlg = Adw.PreferencesDialog(title="Privacy Settings")
        page = Adw.PreferencesPage()
        dlg.add(page)

        data = Adw.PreferencesGroup(title="Browsing data",
            description="Bookmarks are always kept.")
        sw = Adw.SwitchRow(title="Clear data when the browser closes",
            subtitle="Cookies, site storage, cache and site logins. You'll be signed out of websites each time.")
        sw.set_active(self.app.settings["clear_on_exit"])
        sw.connect("notify::active", lambda r, _p: self.app.set_setting("clear_on_exit", r.get_active()))
        data.add(sw)
        now = Adw.ButtonRow(title="Clear Browsing Data Now…") if hasattr(Adw, "ButtonRow") else None
        if now is None:
            now = Adw.ActionRow(title="Clear browsing data now", activatable=True)
            now.add_suffix(Gtk.Image(icon_name="go-next-symbolic"))
        now.connect("activated", lambda *_: (dlg.close(), self._confirm_clear_data()))
        data.add(now)
        page.add(data)

        dns = Adw.PreferencesGroup(title="Encrypted DNS",
            description=("Hides the websites you look up from your network and internet "
                         "provider by encrypting DNS (DNS-over-TLS). Applies to the whole "
                         "computer and asks for your password. If a public Wi-Fi sign-in "
                         "page won't load, turn this off."))
        combo = Adw.ComboRow(title="Provider",
                             model=Gtk.StringList.new([n for _, n in DNS_PROVIDERS]))
        status = Adw.ActionRow(title="Status")
        status_lbl = Gtk.Label(css_classes=["dim-label"]); status.add_suffix(status_lbl)
        spinner = Gtk.Spinner(); status.add_suffix(spinner)
        ids = [i for i, _ in DNS_PROVIDERS]

        def show_state(provider, note=None):
            names = dict(DNS_PROVIDERS)
            status_lbl.set_label(note or ("Off" if provider == "off" else
                                          f"On — {names.get(provider, provider)}"))
        cur = _dns_provider()
        combo.set_selected(ids.index(cur) if cur in ids else 0)
        show_state(cur)
        if not os.path.exists(DNS_HELPER):
            combo.set_sensitive(False)
            show_state(cur, "Unavailable (lawleyos-dns not installed)")

        def apply(*_):
            want = ids[combo.get_selected()]
            if want == _dns_provider(): return
            combo.set_sensitive(False); spinner.start(); show_state(want, "Applying…")
            argv = ["pkexec", DNS_HELPER] + (["off"] if want == "off" else ["set", want])
            try:
                proc = Gio.Subprocess.new(argv, Gio.SubprocessFlags.STDOUT_PIPE |
                                                Gio.SubprocessFlags.STDERR_MERGE)
            except GLib.Error as e:
                return done(None, None, str(e))
            proc.communicate_utf8_async(None, None, done, None)

        def done(proc, res, err=None, *_):
            spinner.stop(); combo.set_sensitive(True)
            now = _dns_provider()
            if err is None:
                try:
                    _ok, out, _e = proc.communicate_utf8_finish(res)
                    code = proc.get_exit_status()
                except GLib.Error as e:
                    out, code = str(e), 1
            else:
                out, code = err, 1
            combo.set_selected(ids.index(now) if now in ids else 0)
            if code == 0:
                show_state(now)
            elif code in (126, 127):        # pkexec: cancelled / not authorised
                show_state(now)
            else:
                show_state(now, "Couldn't change it — previous setting kept")
                print(f"lawleyos-browser: lawleyos-dns: {(out or '').strip()}", file=sys.stderr)

        combo.connect("notify::selected", apply)
        dns.add(combo); dns.add(status)
        page.add(dns)

        rep = Adw.PreferencesGroup(title="Tracking report")
        rsw = Adw.SwitchRow(title="Show what websites try to track",
            subtitle=("Adds a shield button next to the address bar listing blocked trackers, "
                      "fingerprinting attempts and other outside sites. Trackers are blocked "
                      "either way. Nothing is saved or sent anywhere."))
        rsw.set_active(self.app.settings["tracking_report"])
        rsw.connect("notify::active", lambda r, _p: self.app.set_setting("tracking_report", r.get_active()))
        rep.add(rsw)
        page.add(rep)
        dlg.present(self)

    def _on_tab_switched(self, view, _p):
        wv = self.current_webview()
        if wv:
            self._sync_url(wv)
            self._sync_nav(wv)
            self.sync_pill()

    def _on_load_changed(self, wv, event, page):
        if event == WebKit.LoadEvent.STARTED:
            page.set_loading(True)
            wv.track = _new_report()
            if wv is self.current_webview(): self.sync_pill()
        elif event == WebKit.LoadEvent.FINISHED:
            page.set_loading(False)
        if wv is self.current_webview():
            self._sync_url(wv); self._sync_nav(wv)

    def _on_progress(self, wv, _p):
        if wv is not self.current_webview(): return
        v = wv.get_estimated_load_progress()
        self.progress.set_fraction(v)
        self.progress.set_visible(0.0 < v < 1.0)

    def _sync_url(self, wv):
        uri = wv.get_uri() or ""
        if uri.startswith("about:"):
            uri = ""
        if not self.url.has_focus():
            self.url.set_text(uri)
        star = "starred-symbolic" if self.app.is_bookmarked(wv.get_uri() or "") else "non-starred-symbolic"
        self.url.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, star)

    def _sync_nav(self, wv):
        self.b_back.set_sensitive(wv.can_go_back())
        self.b_fwd.set_sensitive(wv.can_go_forward())

    # ---- actions ----
    def on_url_activate(self, entry):
        wv = self.current_webview()
        if wv: wv.load_uri(to_uri(entry.get_text()))
    def on_back(self):
        wv = self.current_webview();  wv and wv.go_back()
    def on_forward(self):
        wv = self.current_webview();  wv and wv.go_forward()
    def on_reload(self):
        wv = self.current_webview();  wv and wv.reload()
    def on_home(self):
        wv = self.current_webview();  wv and wv.load_uri(HOME_URL)
    def on_star(self):
        wv = self.current_webview()
        if not wv: return
        self.app.toggle_bookmark(wv.get_uri() or "", wv.get_title() or "")
        self._sync_url(wv)
    def _zoom(self, delta):
        wv = self.current_webview()
        if not wv: return
        wv.set_zoom_level(1.0 if delta == 0 else max(0.4, min(3.0, wv.get_zoom_level() + delta)))
    def _about(self):
        d = Adw.AboutDialog(application_name="LawleyOS Portal",
            application_icon="lawleyos-browser", version="1.1.3",
            developer_name="LawleyOS", website=HOME_URL,
            comments="A fast, private web browser from the LawleyOS project.\nPrivate by default: tracker/ad blocking, HTTPS-only, no third-party cookies,\ntracking-link cleanup and Global Privacy Control. Search: DuckDuckGo.")
        d.present(self)


if __name__ == "__main__":
    Browser().run(sys.argv)
