Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,110 @@
"""Exercise alarms and Android permission boundaries in the packaged bridge."""
import functools
import json
import os
import threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from playwright.sync_api import sync_playwright
# This harness mocks extension storage/native messaging, not the Gaia service code.
root = Path(__file__).resolve().parents[2]
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/test.html":
body = b'<!doctype html><html><head><script type="module" src="/background.js"></script></head><body>Bridge test</body></html>'
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
server = ThreadingHTTPServer(
("127.0.0.1", 0),
functools.partial(Handler, directory=str(root / "android/app/src/main/assets/bridge")),
)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
with sync_playwright() as p:
b = p.chromium.launch(executable_path=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE"))
page = b.new_page()
messages = []
errors = []
page.on("console", lambda msg: messages.append(msg.text))
page.on("pageerror", lambda e: errors.append(str(e)))
page.add_init_script(
"""
const values={};window.nativeCalls=[];window.events=[];
window.browser={storage:{local:{get:async key=>({[key]:structuredClone(values[key])}),set:async data=>Object.assign(values,structuredClone(data))}},runtime:{
connectNative:()=>({onMessage:{addListener(fn){window.nativeEvent=fn;}},onDisconnect:{addListener(){}},postMessage(){}}),
sendNativeMessage:async(app,message)=>{nativeCalls.push(message);return message.type==='android.snapshot'?{}:null;},onConnect:{addListener(fn){window.connectPort=fn;}}
}};
window.makePort=url=>{
let receive,seq=0;const waiting=new Map();
connectPort({name:'gaia',sender:{url},onMessage:{addListener(fn){receive=fn;}},onDisconnect:{addListener(){}},postMessage(msg){if(msg.event)events.push({url,...msg.event});if(waiting.has(msg.id)){waiting.get(msg.id)(msg.reply);waiting.delete(msg.id);}}});
return (method,params)=>new Promise(resolve=>{const id=++seq;waiting.set(id,resolve);receive({id,request:{version:1,id,method,params}});});
};
"""
)
page.goto(f"http://127.0.0.1:{server.server_port}/test.html")
page.wait_for_function('typeof connectPort === "function"')
results = page.evaluate(
"""async()=>{
const clock=makePort('http://clock.localhost:18765/onring.html');
const before=await clock('alarms.request',{operation:'getAll'});
if(before.error)throw Error(JSON.stringify(before));
const add=await clock('alarms.request',{operation:'add',date:Date.now()+60000,respectTimezone:'honorTimezone',local:[2026,8,23,9,0,0,0],data:{label:'test'}});
if(add.error)throw Error(JSON.stringify(add));
const list=await clock('alarms.request',{operation:'getAll'});
await clock('alarms.request',{operation:'remove',id:add.result});
const empty=await clock('alarms.request',{operation:'getAll'});
const gallery=makePort('http://gallery.localhost:18765/index.html');
const denied=await gallery('android.settings',{panel:'wifi'});
const cameraDenied=await gallery('android.capture',{});
const settingsDenied=await gallery('settings.set',{values:{'screen.brightness':.4}});
const settings=makePort('http://settings.localhost:18765/index.html');
nativeCalls.length=0;
const malformed=await settings('settings.set',{values:{'screen.brightness':.5,'':true}});
const noSideEffect=malformed.error?.code==='INVALID_KEY' && nativeCalls.length===0;
const notificationDenied=await gallery('android.notification',{operation:'show',owner:'settings',title:'Spoof'});
const smsDenied=await clock('android.sms',{number:'123',body:'test'});
const foreign=makePort('https://example.com/');
const foreignDenied=await foreign('android.snapshot',{});
const system=makePort('http://system.localhost:18765/index.html');
events.length=0;
await nativeEvent({type:'notification-click',data:{owner:'settings',id:1}});
const queued=!events.some(e=>e.type==='launch');
const forbiddenReady=await gallery('android.systemReady',{});
await system('android.systemReady',{});
const replayed=events.some(e=>e.type==='launch' && e.data.manifestURL==='http://settings.localhost:18765/manifest.webapp');
return {coldNotificationWaitsForGaia:queued && replayed,systemReadinessPermission:forbiddenReady.error?.code==='PERMISSION_DENIED',invalidSettingNoNativeSideEffect:noSideEffect,notificationPermissions:notificationDenied.error?.code==='PERMISSION_DENIED',smsPermissions:smsDenied.error?.code==='PERMISSION_DENIED',started:true,stored:list.result.length===1,removed:empty.result.length===0,
hardwarePermissions:denied.error?.code==='PERMISSION_DENIED' && cameraDenied.error?.code==='PERMISSION_DENIED',
settingsPermissions:settingsDenied.error?.code==='PERMISSION_DENIED',
externalOriginDenied:foreignDenied.error?.code==='PERMISSION_DENIED'};
}"""
)
assert all(results.values()), results
assert not errors, errors
(root / "logs/repository-preparation").mkdir(parents=True, exist_ok=True)
(root / "logs/repository-preparation/android-bridge-test.json").write_text(
json.dumps(
{
"results": results,
"scope": "Packaged extension in a browser; native messaging and extension storage mocked. Not an Android device test.",
},
indent=2,
)
)
print(results)
b.close()
finally:
server.shutdown()
server.server_close()

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Reproduce first camera consent on the test emulator, without restarting afterward."""
import importlib.util
import json
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
def adb(*args):
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
assert adb("shell", "getprop", "ro.kernel.qemu") == "1"
adb("shell", "pm", "revoke", "org.vulpes_os.preview", "android.permission.CAMERA")
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
adb("forward", "tcp:6088", "localabstract:org.vulpes_os.preview/firefox-debugger-socket")
for _ in range(60):
if probe.run("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"').get(
"value"
):
break
time.sleep(0.3)
else:
raise TimeoutError("Gaia boot")
probe.run(
"system",
'VulpesCompat.call("apps.launch",{manifestURL:"http://camera.localhost:18765/manifest.webapp"})',
)
subprocess.run(
["python3", str(ROOT / "android/tools/ui-android.py"), "While using the app"],
check=True,
timeout=30,
)
for _ in range(60):
state = probe.run(
"camera",
'({live:!!app.camera.mozCamera && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live"),loaded:!!app.loaded,hidden:document.hidden,busy:app.camera.isBusy,width:document.querySelector(".viewfinder video")?.videoWidth,text:document.body.innerText})',
).get("value", {})
if state.get("live") and state.get("width") and state.get("loaded") and not state.get("busy"):
break
time.sleep(0.3)
else:
raise AssertionError(state)
result = {"first_consent_without_restart": state, "scope": "Android 16 x86_64 emulator"}
(ROOT / "android/logs/camera-first-permission.json").write_text(
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(result, indent=2, ensure_ascii=False))

View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Exercise Gaia Photo/Gallery on an Android emulator with its virtual sensor."""
import importlib.util
import json
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
def adb(*args):
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
assert (
adb("shell", "getprop", "ro.kernel.qemu") == "1"
), "Use a virtual sensor, not a personal camera."
def ev(app, code):
reply = probe.run(app, code)
if "error" in reply:
raise AssertionError(reply)
return reply.get("value")
def wait(app, code):
for _ in range(60):
value = ev(app, code)
if value:
return value
time.sleep(0.3)
raise TimeoutError(app + ": " + code)
def launch(app):
ev(
"system",
'VulpesCompat.call("apps.launch",{manifestURL:"http://'
+ app
+ '.localhost:18765/manifest.webapp"})',
)
wait(app, 'document.readyState==="complete" && !document.hidden')
result = {"scope": "Android 16 x86_64 emulator, virtual camera; physical phones untested."}
wait("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"')
ev("system", "(ScreenManager.turnScreenOn(true),true)")
time.sleep(.5)
ev("system", "(lockScreen.unlock(true),true)")
wait("system", "!lockScreen.locked")
time.sleep(.5)
launch("camera")
wait(
"camera",
'!!app.loaded && !!app.camera.mozCamera && !app.camera.isBusy && !!document.querySelector(".viewfinder video")?.videoWidth',
)
result["preview"] = ev(
"camera",
'(()=>{const v=document.querySelector(".viewfinder video");return {width:v.videoWidth,height:v.videoHeight,cameras:navigator.mozCameras.getListOfCameras()}})()',
)
result["original_gaia_ui"] = ev(
"camera",
'!!document.querySelector(".js-capture") && !document.querySelector(".vulpes-capability")',
)
assert result["original_gaia_ui"]
ev(
"camera",
'(async()=>{window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;return true})()',
)
point = ev(
"camera",
'(()=>{const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:(mozInnerScreenX+r.x+r.width/2)*devicePixelRatio,y:(mozInnerScreenY+r.y+r.height/2)*devicePixelRatio}})()',
)
adb("shell", "input", "tap", str(round(point["x"])), str(round(point["y"])))
photo = wait(
"camera",
'(async()=>(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).find(f=>!__beforeCapture.includes(f.name)))()',
)
assert photo["size"] > 0 and photo["type"] == "image/jpeg"
result["photo"] = photo
launch("gallery")
result["camera_stops_when_hidden"] = wait(
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
)
name = json.dumps(photo["name"])
result["gallery_indexes_photo"] = wait(
"gallery",
'typeof files!=="undefined" && files.some(f=>f.name==='
+ name
+ " && f.metadata.width>0 && f.metadata.thumbnail)",
)
ev(
"gallery",
'(()=>{const e=[...document.querySelectorAll(".thumbnailImage")].find(e=>e.dataset.filename==='
+ name
+ ");e.click();return true})()",
)
result["gallery_opens_photo"] = wait(
"gallery", 'document.body.classList.contains("fullscreenView")'
)
result["gallery_import_button"] = ev(
"gallery", '!!document.querySelector("gaia-header [data-vulpes-import]") && !document.querySelector(".vulpes-import")'
)
assert result["gallery_import_button"]
ev("gallery", "(launchCameraApp(),true)")
wait("camera", "!document.hidden")
result["camera_resumes"] = wait(
"camera",
'!!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
ev("camera", "(window.__streamBefore=app.camera.mozCamera,true)")
adb("shell", "input", "keyevent", "KEYCODE_HOME")
result["camera_stops_in_android_background"] = wait(
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
)
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
result["camera_resumes_from_android_background"] = wait(
"camera",
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
result["unavailable_controls"] = ev(
"camera",
'[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
)
assert result["unavailable_controls"]
result["errors"] = ev(
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
)
assert not result["errors"], result
launch("gallery")
(ROOT / "android/logs/camera-results.json").write_text(
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(result, indent=2, ensure_ascii=False))

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Exercise Android background/resume and renderer termination on a test device."""
import importlib.util,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'android/tools'))
spec=importlib.util.spec_from_file_location('probe',ROOT/'android/tools/probe-android.py');probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
from rdp import RDP
ADB=str(ROOT/'android/toolchain/sdk/platform-tools/adb')
def adb(*args):return subprocess.check_output([ADB,*args],text=True).strip()
def wait_app():
end=time.monotonic()+30
while time.monotonic()<end:
try:
result=probe.run('system','({ready:document.readyState,marker:window.__lifecycleMarker,frames:[...document.querySelectorAll("iframe")].map(f=>f.src)})')
if result.get('value',{}).get('ready')=='complete':return result['value']
except (Exception,):pass
time.sleep(.5)
raise TimeoutError('Gaia did not recover')
report={}
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
wait_app();probe.run('system','window.__lifecycleMarker="lifecycle-20260924"')
cycles=[]
for i in range(10):
adb('shell','input','keyevent','KEYCODE_HOME')
adb('shell','am','start','-a','android.settings.SETTINGS')
time.sleep(.5)
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
state=wait_app();cycles.append(state['marker']=='lifecycle-20260924')
print('Resume cycle',i+1,cycles[-1],flush=True)
assert all(cycles),cycles
report['ten_switches_preserve_document']=cycles
r=RDP();tab=next(t for t in r.call('root','listTabs')['tabs'] if 'system.localhost' in t.get('url',''));target=r.call(tab['actor'],'getTarget')['frame'];r.s.close()
pid=str(target['processID']);name=adb('shell','ps','-p',pid,'-o','NAME')
assert 'org.vulpes_os.preview:tab' in name,name
adb('shell','input','keyevent','KEYCODE_HOME');adb('shell','run-as','org.vulpes_os.preview','kill','-9',pid)
time.sleep(.5);adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
state=wait_app();assert state.get('marker') is None,state
for _ in range(30):
try:
home=probe.run('homescreen','({icons:document.querySelectorAll("gaia-app-icon").length,ready:document.readyState})')['value']
if home['icons']>10:break
except Exception:pass
time.sleep(.5)
assert home['icons']>10,home
report['renderer_killed_in_background_recovers']=home
report['snapshot']=probe.run('system','VulpesCompat.call("android.snapshot",{})')['value']
report['scope']='Android 16 x86_64 emulator; not a physical-device validation.'
(ROOT/'android/logs/integration/lifecycle.json').write_text(json.dumps(report,indent=2))
print(json.dumps(report,indent=2),flush=True)

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Observe real Gaia wallpaper events over an ADB-forwarded debug connection."""
import json
import sys
import time
from rdp import RDP
def evaluate(source):
r = RDP()
try:
result = r.eval(source)
if result.get("hasException"):
raise RuntimeError(result)
return result.get("result")
finally:
r.s.close()
duration = int(sys.argv[1]) if len(sys.argv) > 1 else 30
evaluate(
"""window.__wallpaperProbe={changes:0,settings:0,start:Date.now()};
addEventListener('wallpaperchange',()=>__wallpaperProbe.changes++);
navigator.mozSettings.addObserver('wallpaper.image',()=>__wallpaperProbe.settings++);
navigator.mozSettings.createLock().get('wallpaper.image').onsuccess=e=>{
const v=e.target.result['wallpaper.image'];
__wallpaperProbe.image={blob:v instanceof Blob,size:v.size,type:v.type};
}; 'probe-started';"""
)
time.sleep(duration)
result = json.loads(
evaluate(
"""JSON.stringify({...__wallpaperProbe,
elapsed:Date.now()-__wallpaperProbe.start,ready:applications.ready,
background:document.getElementById('screen').style.backgroundImage})"""
)
)
print(json.dumps(result, indent=2))
assert result["ready"], "Gaia not ready"
assert result.get("image", {}).get("blob"), "Wallpaper was not restored as a Blob"
assert result["image"]["size"] > 0, "Empty wallpaper"
assert (
result["changes"] == 0 and result["settings"] == 0
), "Wallpaper is still changing without user action"
assert "blob:" in result["background"], "Wallpaper missing from the screen"

View file

@ -0,0 +1,19 @@
import sys
import json
from rdp import RDP
r = RDP()
targets = r.call("root", "listProcesses")["processes"]
for p in targets:
if p["isParent"]:
continue
t = r.call(p["actor"], "getTarget")["process"]
if sys.argv[1] not in t.get("remoteType", ""):
continue
a = r.call(t["consoleActor"], "evaluateJSAsync", text=sys.argv[2])
while True:
q = r.recv()
if q.get("type") == "evaluationResult":
print(json.dumps(q, indent=2))
break
r.s.close()

View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Snapshot the same Gaia assets/overlays as the desktop, without changing them."""
import hashlib
import json
import re
import shutil
import subprocess
import zipfile
import sys
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
from project import build_info, validate_assets, release_info
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
OUT.mkdir(parents=True, exist_ok=True)
PORT = 18765
pattern = re.compile(
r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org|http://([a-zA-Z0-9_-]+)\.localhost:8765"
)
def translate(text):
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:{PORT}", text)
APPS = ROOT / "assets/webapps"
registry = []
for manifest in sorted(APPS.glob("*/manifest.webapp")):
name = manifest.parent.name.removesuffix(".gaiamobile.org")
if not re.fullmatch("[a-z0-9_-]+", name):
continue
origin = f"http://{name}.localhost:{PORT}"
registry.append(
dict(
id=name,
origin=origin,
manifestURL=origin + "/manifest.webapp",
manifest=json.loads(translate(manifest.read_text())),
)
)
info = build_info()
release = release_info()
revision = info["revision"]
commit_time = str(info["timestamp"])
source_overrides = {
"system": [
"js/launcher.js",
"js/gaia_scheduler.js",
"js/keyboard_manager.js",
"js/core.js",
"js/rocketbar.js",
]
}
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
hashes = {}
def put(name, data):
z.writestr(name, data)
hashes[name] = hashlib.sha256(data).hexdigest()
for app in registry:
folder = APPS / (app["id"] + ".gaiamobile.org")
files = {p.relative_to(folder).as_posix(): p for p in folder.rglob("*") if p.is_file()}
common = ROOT / "overrides/shared"
if common.is_dir():
for source in common.rglob("*"):
if source.is_file():
files["shared/" + source.relative_to(common).as_posix()] = source
overlay = ROOT / "overrides" / folder.name
if overlay.exists():
files.update(
{
p.relative_to(overlay).as_posix(): p
for p in overlay.rglob("*")
if p.is_file() and p.name != "manifest.webapp"
}
)
for name in source_overrides.get(app["id"], []):
files[name] = ROOT / "gaia/apps" / app["id"] / name
for name, p in files.items():
data = p.read_bytes()
if p.suffix in textsuffix:
text = translate(data.decode("utf8"))
if p.suffix in {".html", ".css", ".js"}:
for a, b in [
("-moz-calc(", "calc("),
("-moz-box-sizing:", "box-sizing:"),
("-moz-plaintext", "plaintext"),
(":-moz-dir(", ":dir("),
("offset-inline-start:", "inset-inline-start:"),
("offset-inline-end:", "inset-inline-end:"),
]:
text = text.replace(a, b)
# Clearing a media URL with null requests the literal /null path.
if app["id"] == "music" and p.suffix == ".js":
text = text.replace("audio.src=null;", "audio.removeAttribute('src');")
if name == "resources/gaia_commit.txt":
text = revision + "\n" + commit_time + "\n"
data = text.encode()
if p.suffix == ".html" and not (app["id"] == "search" and name.startswith("home/")):
data = data.replace(b"<head>", head, 1)
if app["id"] == "communications" and name.startswith("dialer/"):
data = data.replace(
b"<head>",
b'<head><script src="/shared/js/accessibility_helper.js"></script>',
1,
)
put(app["id"] + "/" + name, data)
for p in (ROOT / "gaia/shared").rglob("*"):
if p.is_file():
data = p.read_bytes()
if p.suffix in textsuffix:
data = translate(data.decode("utf8")).encode()
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
for name in ["gaia-compat.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
text = (
(ROOT / "host" / name)
.read_text()
)
put("_vulpes/" + name, translate(text).encode())
put("_vulpes/settings-codec.js", (ROOT / "android/ui/settings-codec.js").read_bytes())
put("_vulpes/android-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
put(
"_vulpes/android.css",
b'html,body{overscroll-behavior:none} html[data-vulpes-app="system"] #keyboard-frame{display:none!important} html[data-vulpes-app="system"] #screen{height:100%!important} input,textarea{touch-action:manipulation}',
)
(OUT / "payload-checksums.json").write_text(json.dumps(hashes, sort_keys=True))
ext = OUT / "bridge"
shutil.copytree(ROOT / "android/extension", ext, dirs_exist_ok=True)
for folder in ["services", "adapters"]:
shutil.copytree(ROOT / folder, ext / folder, dirs_exist_ok=True)
for name in ["Connections", "Contacts", "Messages"]:
text = (
(ROOT / "host" / f"{name}.sys.mjs")
.read_text()
.replace("Services.uuid.generateUUID().toString()", "crypto.randomUUID()")
)
(ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
(ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))
wallpaper = ROOT / "gaia/apps/default_theme/wallpapers/Illus_Flow2_Blue@2x.jpg"
if not wallpaper.is_file():
raise RuntimeError("Default wallpaper is missing: " + str(wallpaper))
settings["wallpaper.image"] = (
"data:image/jpeg;base64," + base64.b64encode(wallpaper.read_bytes()).decode()
)
settings.update(
{
"ftu.manifestURL": "",
"lockscreen.enabled": True,
"screen.timeout": 0,
"language.current": "fr",
"software-button.enabled": False,
"metrics.selectedMetrics.level": "None",
"ftu.pingEnabled": False,
"search.suggestions.enabled": False,
"search.marketplace.url": "",
"gaia.system.checkForUpdates": False,
"deviceinfo.os": release["gaia"],
"deviceinfo.software": "Vulpes OS " + release["version"],
"deviceinfo.product_model": "Vulpes Android Preview",
"deviceinfo.product_device": "android",
"deviceinfo.platform_version": release["androidEngine"],
"deviceinfo.platform_build_id": release["androidEngineBuildID"],
}
)
(ext / "defaults.json").write_text(json.dumps(settings, ensure_ascii=False))
(OUT / "build-info.json").write_text(
json.dumps(
{
"revision": revision,
"geckoview": release["geckoview"],
"version": release["version"],
"minSdk": 26,
"targetSdk": 37,
}
)
)
print(
f'Packaged {len(registry)} Gaia apps, {round((OUT/"gaia.zip").stat().st_size/1024/1024,1)} MiB'
)

View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Install the locked Linux x86_64 Android build tools in the ignored toolchain/."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parents[1]
TOOLS = ROOT / "toolchain"
def fetch(name, entry):
path = TOOLS / "downloads" / name
path.parent.mkdir(parents=True, exist_ok=True)
algorithm = "sha256" if "sha256" in entry else "sha1"
if not path.exists():
partial = path.with_suffix(path.suffix + ".partial")
with urllib.request.urlopen(entry["url"], timeout=60) as source, partial.open("wb") as out:
shutil.copyfileobj(source, out)
partial.replace(path)
with path.open("rb") as stream:
digest = hashlib.file_digest(stream, algorithm).hexdigest()
if digest != entry[algorithm]:
raise RuntimeError(f"Checksum mismatch; archive left unextracted: {path}")
return path
def extract(archive, member_root, target):
if target.exists():
raise RuntimeError(f"Incomplete installation left untouched: {target}")
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".install-", dir=TOOLS) as temporary:
stage = Path(temporary)
if archive.name.endswith(".tar.gz"):
with tarfile.open(archive) as package:
package.extractall(stage, filter="data")
else:
with zipfile.ZipFile(archive) as package:
for info in package.infolist():
path = stage / info.filename
if not path.resolve().is_relative_to(stage) or "\\" in info.filename:
raise RuntimeError("Unsafe toolchain archive entry")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise RuntimeError("Symlink in toolchain ZIP archive")
package.extract(info, stage)
mode = (info.external_attr >> 16) & 0o777
if mode and path.is_file():
path.chmod(mode)
(stage / member_root).rename(target)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--check", action="store_true", help="Check installed tools without downloading"
)
args = parser.parse_args()
if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"):
raise RuntimeError("This bootstrap currently supports Linux x86_64 only")
lock = json.loads((ROOT / "toolchain-lock.json").read_text())
jdk = TOOLS / "jdk-21.0.12.1+1"
gradle = TOOLS / "gradle-9.5.0"
sdk = TOOLS / "sdk"
installs = [
("jdk.tar.gz", "jdk-21.0.12.1+1", jdk, jdk / "bin/java"),
("gradle-9.5.zip", "gradle-9.5.0", gradle, gradle / "bin/gradle"),
(
"commandlinetools.zip",
"cmdline-tools",
sdk / "cmdline-tools/latest",
sdk / "cmdline-tools/latest/bin/sdkmanager",
),
]
missing = [
str(executable.relative_to(ROOT))
for _, _, _, executable in installs
if not executable.is_file()
]
packages = [sdk / "platforms/android-37.1/android.jar", sdk / "build-tools/37.0.0/apksigner"]
if args.check:
missing += [str(path.relative_to(ROOT)) for path in packages if not path.is_file()]
if missing:
raise RuntimeError("Missing tools: " + ", ".join(missing))
else:
TOOLS.mkdir(exist_ok=True)
for name, member, target, executable in installs:
if not executable.is_file():
extract(fetch(name, lock["archives"][name]), member, target)
if not all(path.is_file() for path in packages):
env = {**os.environ, "JAVA_HOME": str(jdk)}
manager = sdk / "cmdline-tools/latest/bin/sdkmanager"
# sdkmanager asks the user to review any licenses not already accepted.
subprocess.run(
[str(manager), "--sdk_root=" + str(sdk), *lock["sdk"]], env=env, check=True
)
print("Android build tools available in", TOOLS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Evaluate a diagnostic in one Gaia app through the ADB-only debugger."""
import json,sys,time
from rdp import RDP
def evaluate(host, script):
r=RDP()
try:
for process in r.call('root','listProcesses')['processes']:
if process['isParent']: continue
target=r.call(process['actor'],'getTarget')['process']
if 'webIsolated' not in target.get('remoteType',''): continue
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue
while True:
value=r.recv()
if value.get('type')=='evaluationResult' and value.get('resultID')==response['resultID']:
if value.get('hasException'): raise RuntimeError(value.get('exceptionMessage',str(value)))
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
break
raise RuntimeError('App not open: '+host)
finally:r.s.close()
def run(host,expression,timeout=30):
start=time.monotonic()+timeout
while True:
try:
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
break
except (RuntimeError, EOFError, OSError) as e:
if time.monotonic()>=start:raise
if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
time.sleep(.3)
until=time.monotonic()+timeout
while time.monotonic()<until:
result=evaluate(host,'window.__androidProbe || "pending"')
if isinstance(result,str) and result != 'pending':return json.loads(result)
time.sleep(.3)
raise TimeoutError(host)
if __name__=='__main__':
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))

60
android/tools/rdp.py Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""ADB-forwarded Firefox debugger helper for local development APKs."""
import socket
import json
import sys
class RDP:
def __init__(self, port=6088):
self.events = []
self.s = socket.create_connection(("127.0.0.1", port), timeout=20)
self.recv()
def recv(self):
n = b""
while not n.endswith(b":"):
c = self.s.recv(1)
if not c:
raise EOFError()
n += c
data = b""
while len(data) < int(n[:-1]):
data += self.s.recv(int(n[:-1]) - len(data))
return json.loads(data)
def call(self, to, kind, **args):
data = json.dumps(dict(to=to, type=kind, **args)).encode()
self.s.sendall(str(len(data)).encode() + b":" + data)
while True:
r = self.recv()
self.events.append(r)
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
return r
def eval(self, text):
tabs = self.call("root", "listTabs")["tabs"]
tab = next(t for t in tabs if "system.localhost" in t.get("url", ""))
frame = self.call(tab["actor"], "getTarget")["frame"]
reply = self.call(frame["consoleActor"], "evaluateJSAsync", text=text)
if "resultID" not in reply:
return reply
while True:
result = self.recv()
if (
result.get("type") == "evaluationResult"
and result.get("resultID") == reply["resultID"]
):
return result
if __name__ == "__main__":
c = RDP()
print(
json.dumps(
c.eval(sys.argv[1]) if len(sys.argv) > 1 else c.call("root", "listTabs"),
ensure_ascii=False,
indent=2,
)
)
c.s.close()

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Read or tap a labeled Android accessibility node on the connected test device."""
import re,sys,subprocess,xml.etree.ElementTree as E
from pathlib import Path
adb=str(Path(__file__).resolve().parents[1]/'toolchain/sdk/platform-tools/adb')
subprocess.run([adb,'shell','uiautomator','dump','/sdcard/vulpes-ui.xml'],stdout=subprocess.DEVNULL,check=True)
nodes=list(E.fromstring(subprocess.check_output([adb,'shell','cat','/sdcard/vulpes-ui.xml'],text=True)).iter('node'))
if len(sys.argv)>1:
needle=sys.argv[1]
for n in nodes:
if needle in (n.get('text'),n.get('content-desc'),n.get('resource-id')):
x1,y1,x2,y2=map(int,re.findall(r'\d+',n.get('bounds')))
subprocess.run([adb,'shell','input','tap',str((x1+x2)//2),str((y1+y2)//2)],check=True);print('Tapped:',needle);break
else:raise SystemExit('Node not found: '+needle)
else:
for n in nodes:
if n.get('text') or n.get('clickable')=='true':print(n.get('text'),n.get('content-desc'),n.get('resource-id'),n.get('bounds'))