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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
110
android/tools/check-bridge.py
Normal file
110
android/tools/check-bridge.py
Normal 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()
|
||||
56
android/tools/check-camera-permission.py
Normal file
56
android/tools/check-camera-permission.py
Normal 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))
|
||||
141
android/tools/check-camera.py
Normal file
141
android/tools/check-camera.py
Normal 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))
|
||||
50
android/tools/check-lifecycle.py
Normal file
50
android/tools/check-lifecycle.py
Normal 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)
|
||||
45
android/tools/check-wallpaper.py
Normal file
45
android/tools/check-wallpaper.py
Normal 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"
|
||||
19
android/tools/inspect-process.py
Normal file
19
android/tools/inspect-process.py
Normal 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()
|
||||
191
android/tools/pack-assets.py
Normal file
191
android/tools/pack-assets.py
Normal 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'
|
||||
)
|
||||
108
android/tools/prepare-toolchain.py
Normal file
108
android/tools/prepare-toolchain.py
Normal 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()
|
||||
43
android/tools/probe-android.py
Normal file
43
android/tools/probe-android.py
Normal 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
60
android/tools/rdp.py
Normal 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()
|
||||
17
android/tools/ui-android.py
Normal file
17
android/tools/ui-android.py
Normal 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'))
|
||||
Loading…
Reference in a new issue