Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -0,0 +1,6 @@
# Private boot trial: keep the vendor services, replace only the foreground UI.
service vulpes-trial /system/bin/sh /vulpes-boot.sh
class late_start
user root
group root
oneshot

View file

@ -0,0 +1,31 @@
#!/system/bin/sh
# SPDX-License-Identifier: MPL-2.0
# A missing runtime or failed launch must return to the installed KaiOS UI.
BASE=/data/local/vulpes-beatles
fallback() {
/system/bin/start b2g
exit 0
}
count=0
while [ "$count" -lt 120 ]; do
[ -f "$BASE/disable-boot" ] && fallback
[ -f "$BASE/runtime-ready" ] && [ -x "$BASE/rootfs/opt/firefox/firefox" ] &&
[ -e /dev/graphics/fb0 ] && [ -S /dev/socket/rilproxy ] && break
count=$((count + 1))
sleep 1
done
[ "$count" -lt 120 ] || fallback
rm -f "$BASE/display-session.done" "$BASE/rootfs/work/gaia-audit.json"
/sbin/vulpes-display --phone-1800s || fallback
# The supervisor has a 30-minute hard deadline. Check startup independently.
count=0
while [ "$count" -lt 150 ]; do
[ -f "$BASE/display-session.done" ] && fallback
if [ -s "$BASE/rootfs/work/gaia-audit.json" ]; then
/system/bin/busybox grep -q '"readyMs"' "$BASE/rootfs/work/gaia-audit.json" && exit 0
fi
count=$((count + 1))
sleep 1
done
touch "$BASE/stop-session"
fallback

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: MPL-2.0
"""Android 6 audio services used by the Nokia firmware, outside Gecko.
Transaction IDs and argument types: AOSP android-6.0.1_r1,
frameworks/av/media/libmedia/{IAudioPolicyService,IAudioFlinger}.cpp.
This adapter is specific to that ABI. No arbitrary Binder transaction is exposed.
"""
import re
import subprocess
import time
class CallAudio:
def __init__(self):
self.mode=0;self.muted=False;self.speaker=False;self.available=False
self.original=None;self.retry_at=0
def transact(self,service,method,*values):
command=['/system/bin/service','call','media.audio_'+service,str(method)]
for value in values:command.extend(('i32',str(value)))
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
match=re.fullmatch(r"Result: Parcel\(([0-9a-fA-F]{8})\s+'[^']*'\)\s*",result.stdout)
if result.returncode or not match:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return int(match[1],16)
def initialize(self):
if self.available:return
if time.monotonic()<self.retry_at:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
try:
muted=self.transact('flinger',18);route=self.transact('policy',6,0)
if muted not in (0,1) or route not in range(16):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
if self.original is None:self.original=(muted,route)
self.muted=bool(muted);self.speaker=route==1;self.available=True
except (OSError,RuntimeError,subprocess.SubprocessError):
self.retry_at=time.monotonic()+5;raise RuntimeError('CALL_AUDIO_UNAVAILABLE') from None
def set_mode(self,mode):
self.initialize()
if self.mode==mode:return
if self.transact('policy',3,mode):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.mode=mode
def update(self,calls):
mode=2 if any(c['state'] in ('active','held','dialing','alerting') for c in calls) else 1 if calls else 0
try:self.set_mode(mode)
except (OSError,RuntimeError,subprocess.SubprocessError):
self.available=False;self.retry_at=time.monotonic()+5
def request(self,value):
operation=value.get('operation')
if operation not in ('status','set'):raise ValueError('INVALID_REQUEST')
for key in ('muted','speaker'):
if key in value and type(value[key]) is not bool:raise ValueError('INVALID_REQUEST')
self.initialize()
if operation=='set':
if 'speaker' in value and value['speaker']!=self.speaker:
if self.transact('policy',5,0,int(value['speaker'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
actual=self.transact('policy',6,0)
if actual!=int(value['speaker']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.speaker=value['speaker']
if 'muted' in value and value['muted']!=self.muted:
if self.transact('flinger',17,int(value['muted'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
if self.transact('flinger',18)!=int(value['muted']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.muted=value['muted']
return {'muted':self.muted,'speaker':self.speaker,'active':self.mode==2,'available':self.available}
def restore(self):
if self.original is None:return
muted,route=self.original
changes=[]
if self.mode:changes.append(('policy',3,(0,)))
if self.speaker!=(route==1):changes.append(('policy',5,(0,route)))
if self.muted!=bool(muted):changes.append(('flinger',17,(muted,)))
for service,method,values in changes:
try:self.transact(service,method,*values)
except (OSError,RuntimeError,subprocess.SubprocessError):pass

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small, independent Fira clock for the Nokia's RGB565 SPI cover panel."""
import ctypes as C
import bisect
import datetime
import fcntl
import json
import mmap
import re
from pathlib import Path
import signal
import struct
import time
def read_json(path):
try:
value=json.loads(Path(path).read_text())
return value if isinstance(value,dict) else {}
except (OSError,ValueError):return {}
SETTINGS=Path('/work/gaia-profile/vulpes-settings-v1.json')
class Animation:
"""Opt-in private test; decode happens on the PC, not in the phone loop."""
FRAME_BYTES=240*240*2
def __init__(self,path):
self.file=Path(path).open('rb');self.data=None
try:
size=self.file.seek(0,2)
if not 12<=size<=12+240*(4+self.FRAME_BYTES):raise ValueError('Animation size')
self.data=mmap.mmap(self.file.fileno(),0,access=mmap.ACCESS_READ)
magic,count=struct.unpack_from('<8sI',self.data)
if magic!=b'VULPANM1' or not 1<=count<=240 or size!=12+count*(4+self.FRAME_BYTES):
raise ValueError('Animation header')
self.ends=[];self.offsets=[];total=0
for index in range(count):
offset=12+index*(4+self.FRAME_BYTES)
duration,=struct.unpack_from('<I',self.data,offset)
if not 50<=duration<=1000:raise ValueError('Animation timing')
total+=duration;self.ends.append(total);self.offsets.append(offset+4)
except Exception:
self.close();raise
def frame(self,elapsed,stride):
phase=int(elapsed*1000)%self.ends[-1]
index=bisect.bisect_right(self.ends,phase);offset=self.offsets[index]
raw=self.data[offset:offset+self.FRAME_BYTES]
if stride!=480:
raw=b''.join(raw[y*480:(y+1)*480]+bytes(stride-480) for y in range(240))
return raw,(self.ends[index]-phase)/1000,index
def close(self):
if self.data is not None:self.data.close()
self.file.close()
def preferences(values):
title=values.get('vulpes.cover.title','VULPES')
title=''.join(c for c in title if c.isprintable()).strip()[:24] if isinstance(title,str) else ''
def color(key):
value=values.get(key,'#33a6ff')
if not isinstance(value,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',value):value='#33a6ff'
return tuple(int(value[i:i+2],16)/255 for i in (1,3,5))
return title or 'VULPES',color('vulpes.cover.titleColor'),color('vulpes.cover.batteryColor')
def labels(state,now):
if abs(now-state.get('recordedAt',0))>20:return ('—','—')
battery=state.get('battery') or {}
level=battery.get('level')
percent=str(round(level*100))+' %' if isinstance(level,(int,float)) and 0<=level<=1 else '—'
radios=state.get('radio') or []
network=next((r.get('operator',{}).get('longName','') for r in radios if r.get('available')), '')
return percent,network[:24] or '—'
def present(panel, variable):
# The display driver may reject a pan during a panel power transition.
# Record the failure and retry later; keep Gaia and the keypad running.
try:
fcntl.ioctl(panel,0x4606,variable,True)
return {'ok':True}
except OSError as error:
return {'ok':False,'errno':error.errno}
class Drawing:
def __init__(self,width,height,stride,font_path='/opt/vulpes/gaia/shared/elements/vulpes-fonts/FiraSans-Regular.otf'):
self.c=C.CDLL('libcairo.so.2');self.ft=C.CDLL('libfreetype.so.6')
p=C.c_void_p; i=C.c_int; d=C.c_double
signatures={
'cairo_image_surface_create_for_data':(p,[p,i,i,i,i]),
'cairo_create':(p,[p]), 'cairo_destroy':(None,[p]),
'cairo_surface_destroy':(None,[p]), 'cairo_surface_flush':(None,[p]),
'cairo_surface_write_to_png':(i,[p,C.c_char_p]),
'cairo_set_source_rgb':(None,[p,d,d,d]), 'cairo_paint':(None,[p]),
'cairo_rectangle':(None,[p,d,d,d,d]), 'cairo_fill':(None,[p]),
'cairo_move_to':(None,[p,d,d]), 'cairo_set_font_size':(None,[p,d]),
'cairo_show_text':(None,[p,C.c_char_p]), 'cairo_text_extents':(None,[p,C.c_char_p,p]),
'cairo_ft_font_face_create_for_ft_face':(p,[p,i]),
'cairo_set_font_face':(None,[p,p]),'cairo_font_face_destroy':(None,[p]),
'cairo_status':(i,[p]),
}
for name,(result,args) in signatures.items():
fn=getattr(self.c,name);fn.restype=result;fn.argtypes=args
self.ft.FT_Init_FreeType.argtypes=[C.POINTER(p)]
self.ft.FT_New_Face.argtypes=[p,C.c_char_p,C.c_long,C.POINTER(p)]
self.ft.FT_Done_Face.argtypes=[p];self.ft.FT_Done_FreeType.argtypes=[p]
self.library=p();self.face=p()
if self.ft.FT_Init_FreeType(C.byref(self.library)):raise RuntimeError('FreeType initialization')
font=str(font_path).encode()
if self.ft.FT_New_Face(self.library,font,0,C.byref(self.face)):raise RuntimeError('Gaia Fira font unavailable')
self.buffer=C.create_string_buffer(stride*height)
self.surface=self.c.cairo_image_surface_create_for_data(self.buffer,4,width,height,stride)
self.context=self.c.cairo_create(self.surface)
self.font=self.c.cairo_ft_font_face_create_for_ft_face(self.face,0)
self.c.cairo_set_font_face(self.context,self.font)
if self.c.cairo_status(self.context):raise RuntimeError('Cairo RGB565 context unavailable')
def text(self,text,size,y,color=(.92,.96,1)):
c=self.c;ctx=self.context;encoded=text.encode()
c.cairo_set_font_size(ctx,size)
extents=(C.c_double*6)();c.cairo_text_extents(ctx,encoded,extents)
# Fit custom titles without cropping wide letters or accented text.
if extents[2]>216:
c.cairo_set_font_size(ctx,size*216/extents[2])
c.cairo_text_extents(ctx,encoded,extents)
c.cairo_set_source_rgb(ctx,*color)
c.cairo_move_to(ctx,120-extents[2]/2-extents[0],y)
c.cairo_show_text(ctx,encoded)
def render(self,state):
c=self.c;ctx=self.context;now=datetime.datetime.now()
c.cairo_set_source_rgb(ctx,.015,.025,.045);c.cairo_paint(ctx)
title,title_color,battery_color=preferences(read_json(SETTINGS))
self.text(title,14,30,title_color)
self.text(now.strftime('%H:%M'),56,110)
self.text(now.strftime('%d / %m / %Y'),16,143,(.6,.7,.8))
battery,network=labels(state,time.time())
self.text(network,15,185)
self.text(battery,17,218,battery_color)
c.cairo_surface_flush(self.surface)
if c.cairo_status(ctx):raise RuntimeError('Cover rendering failed')
return self.buffer.raw
def close(self):
self.c.cairo_destroy(self.context);self.c.cairo_surface_destroy(self.surface)
self.c.cairo_font_face_destroy(self.font);self.ft.FT_Done_Face(self.face)
self.ft.FT_Done_FreeType(self.library)
def main():
if C.sizeof(C.c_void_p)!=4:raise RuntimeError('Nokia ARM32 framebuffer layout required')
running=True
def stop(*_):
nonlocal running
running=False
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
with open('/dev/fb1','r+b',buffering=0) as panel:
variable=bytearray(160);fixed=bytearray(68)
fcntl.ioctl(panel,0x4600,variable,True);fcntl.ioctl(panel,0x4602,fixed,True)
v=struct.unpack('=40I',variable);stride=struct.unpack_from('=I',fixed,44)[0]
length=struct.unpack_from('=I',fixed,20)[0]
if (v[0:2]!=(240,240) or v[6]!=16 or v[8:10]!=(11,5) or
v[11:13]!=(5,6) or v[14:16]!=(0,5) or v[4]!=0):
raise RuntimeError('Unsupported external panel format: '+repr(v[:17]))
offset=v[5]*stride;size=240*stride
if stride<480 or offset+size>length:raise RuntimeError('Framebuffer bounds invalid')
with mmap.mmap(panel.fileno(),length) as pixels:
original=pixels[offset:offset+size];drawing=Drawing(240,240,stride)
animation=None
try:animation=Animation('/work/cover-animation.rgb565')
except (OSError,ValueError) as error:
if not isinstance(error,FileNotFoundError):print('Cover animation unavailable:',type(error).__name__,flush=True)
try:
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
drawing.c.cairo_surface_write_to_png(drawing.surface,b'/work/cover-preview.png')
Path('/work/cover-ready').write_text('ready\n')
print('Cover ready: 240x240 RGB565, Gaia Fira Sans',flush=True)
last=0;was_on=False;last_error=None;animation_start=None;submitted=0;failed=0;pan_seconds=0
while running:
on=read_json('/work/lid-state.json').get('externalOn') is True
now=time.monotonic()
if on and not was_on and animation:
animation_start=now;submitted=0;failed=0;pan_seconds=0
if on and animation_start is not None:
elapsed=now-animation_start
if elapsed<8:
frame,delay,_=animation.frame(elapsed,stride)
pixels[offset:offset+size]=frame
before=time.monotonic();result=present(panel,variable)
pan_seconds+=time.monotonic()-before
if result['ok']:submitted+=1
else:failed+=1
was_on=on;last=0
time.sleep(max(.005,delay-(time.monotonic()-now)))
continue
Path('/work/cover-animation-report.json').write_text(json.dumps({
'seconds':elapsed,'submissions':submitted,'failed':failed,
'submissionsPerSecond':submitted/elapsed,
'meanPanMs':1000*pan_seconds/max(1,submitted+failed)}))
animation_start=None;last=0
if not on:animation_start=None
if on and (not was_on or time.monotonic()-last>=5):
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
result=present(panel,variable)
Path('/work/cover-refresh.json').write_text(json.dumps({**result,'recordedAt':time.time()}))
if not result['ok'] and result['errno']!=last_error:
print('Cover refresh deferred, errno:',result['errno'],flush=True)
if result['ok'] and last_error is not None:print('Cover refresh recovered',flush=True)
last_error=result.get('errno')
last=time.monotonic()
was_on=on;time.sleep(.2)
finally:
Path('/work/cover-ready').unlink(missing_ok=True)
pixels[offset:offset+size]=original;drawing.close()
if animation:animation.close()
if __name__=='__main__':main()

View file

@ -0,0 +1,200 @@
{
"schema": 1,
"device": "beatles",
"name": "Nokia 2720 Flip",
"architecture": "armv7",
"abi": "armeabi-v7a",
"soc": "MSM8905",
"board": "msm8909",
"taVariant": null,
"observedFirmware": {
"kaios": "2.5.2.2",
"gecko": "48.0a2",
"build": "40.00.17.01",
"androidHardwareBase": "6.0.1",
"androidApi": 23,
"kernel": "3.10.49-g8801858442f"
},
"display": {
"internal": {
"width": 240,
"height": 320
},
"external": {
"width": 240,
"height": 240
},
"touchscreen": false,
"clamshell": {
"sysfs": "/sys/devices/soc.0/hall_sensor1.73/hall_status",
"rawOpen": 0,
"rawClosed": 1,
"evidence": "Physical inversion reported by owner on 2026-10-18; software polarity corrected"
}
},
"memory": {
"kernelVisibleKiB": 409248,
"zramSwapKiB": 262140
},
"access": {
"adb": true,
"adbUid": 2000,
"debuggerChromeProcessUid": 0,
"bootloaderUnlocked": null
},
"validation": {
"nativeArmv7UserspaceProbe": true,
"diagnosticAppOnExistingKaiOS": true,
"newGeckoRuntime": true,
"gaiaBoot": true,
"physicalKeys": true,
"customBootImage": true,
"bootPartitionBackupsVerified": true,
"restorationTested": false,
"physicalKeysEvidence": "Owner reports diagnostic works well, 2026-10-17; no exhaustive keypad matrix qualification.",
"predictiveT9DictionariesBuilt": true,
"predictiveT9DictionaryLanguages": 42,
"predictiveT9PhysicalQualification": false,
"newGeckoOffscreenHtmlCssJs": true,
"newGeckoInteractiveDisplay": true,
"nativeXorgFramebuffer": true,
"framebufferCaptureVerified": true,
"nativeKeypadEventQualification": true,
"nativeRilStatus": true,
"nativeBatteryStatus": true,
"gaiaSimAndSignalStatus": true,
"nativeSms": true,
"nativeCalls": true,
"kernelEvdevKeypadQualification": true,
"nativePhoneBroker": true,
"nativeCallList": true,
"nativeAudioServiceAccess": true,
"nativeSmsCodecTests": true,
"externalDisplayRendered": true,
"physicalClamshellCycle": true,
"externalDisplayRefresh": true,
"nativeCallAudio": true,
"userQualification": "2026-10-18: owner confirms call audio in both directions and SMS; previous confirmation of clamshell/backlight operation. Not an exhaustive reliability test.",
"customBootImageBooted": false,
"persistentRuntime": true,
"persistentRuntimeSurvivedReboot": true,
"nativeVolumeControls": true,
"nativeBacklightControl": true,
"boundedVibration": true,
"nativeRadioPower": true,
"gaiaAirplaneMode": true,
"airplaneModeQualification": "tickets/evidence-2026-10-20/nokia-airplane.json"
},
"flashReady": false,
"evidence": [
"logs/nokia2720-inventory-20261017/inventory.json",
"tundra/out/beatles/armv7-smoke/device-test.json",
"tundra/out/beatles/display-diagnostic/install-report.json",
"tundra/out/beatles/backups/20261017/verification.json",
"tundra/out/beatles/gecko-armhf/version-result.json",
"tundra/out/beatles/gecko-armhf/render-result.json",
"tundra/out/beatles/gecko-armhf/gecko.png",
"tundra/out/beatles/gecko-156.0.1/engine-staging.json",
"tundra/out/beatles/gecko-156.0.1/version-result.json",
"tundra/out/beatles/gecko-156.0.1/render-result.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205052/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/framebuffer-home-156.png",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205433/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/qualification.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-230112/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-233954/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105254/camera-frame.png",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-photo.jpg",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-frame-encoded.jpg",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-110735/display-session.log"
],
"experimentalEngine": {
"version": "156.0.1",
"architecture": "armv7 / hard-float",
"source": "Mozilla release sources, locally cross-compiled",
"sourceRevision": "6f2c158dfc7e9693f880fad2510ceb51a158c069",
"sourcePatches": [],
"mode": "shared Gaia, Xorg/fbdev software display, bounded private session with Nokia radio and Wi-Fi adapters"
},
"limitations": [
"KaiOS kernel and existing firmware; no standalone boot image",
"KaiOS stock Camera is confirmed working by the user; Vulpes Gecko 156 still has no beatles camera adapter. The standalone QCamera diagnostic returned empty frames, so its output is not evidence of a sensor or kernel fault",
"Software framebuffer around 11 submissions/second; no GPU acceleration",
"Multi-tap input qualified; offline predictive T9 is implemented for 42 Gaia keyboard locales, pending physical qualification",
"Gecko runs as uid 2000; root watchdog and Xorg restore KaiOS after each trial",
"First SIM only; no data bearer, MMS or camera adapter; sustained radio reliability and incoming call lifecycle still need qualification",
"External display refresh retries driver refusals during panel transitions",
"Bluetooth firmware backend exists, but Vulpes has no Nokia adapter; pairing and profiles are not available",
"Predictive T9 lexicons are generated from Gaia keyboard wordlists; 12,000 frequent words per locale, loaded on demand. User-dictionary learning is not implemented"
],
"privateBootBuild": {
"path": "tundra/out/beatles/boot-trial-20261018-r5",
"signed": false,
"bootTested": false,
"standalone": false,
"fastbootDetected": false,
"bootloaderLockState": null
},
"hardwareInventory": {
"camera": {
"count": 1,
"facing": "back",
"orientation": 270,
"moduleApi": "1.0",
"gaiaAdapter": false,
"maxPictureSize": [
1600,
1200
],
"focusModes": [
"fixed"
],
"maxDigitalZoom": 6.06,
"firmwareOpenReleaseVerified": true,
"advertisedFlashModes": [
"off",
"auto",
"on",
"torch"
],
"flashPhysicallyVerified": false,
"temporaryProbe": {
"date": "2026-10-21",
"backend": "mm-qcamera-app as Android camera uid 1006",
"previewUi": "QCamera preview starts; frames are not yet delivered to Gaia",
"snapshotYuv": "NV21 1280x960 captured and visually decoded",
"snapshotJpeg": "invalid for use; nearly uniform gray/black",
"gaiaAdapter": false,
"evidence": "tickets/2026-10-21.md",
"cameraDaemon": "qcamerasvr running; /system/bin/mm-qcamera-daemon (Android camera UID) keeps /dev/video0 busy after B2G stops",
"v4l2Direct": "unavailable; Qualcomm interface client required"
}
},
"volumeKeys": {
"up": "event3/115",
"down": "event2/114"
},
"sensors": {
"modulePresent": true,
"enumeratedAsShell": 0,
"enumeratedAsRoot": 0,
"automaticBrightnessAvailable": false,
"accelerometerAvailable": false
},
"wifi": {
"interface": "wlan0",
"controlSocket": "wpa_wlan0",
"gaiaAdapter": true,
"qualification": "tickets/evidence-2026-10-20/nokia-wifi.json"
},
"evidence": "tundra/out/beatles/hardware-inventory-20261018",
"qualification": "tundra/out/beatles/hardware-qualification-20261018/controls-final.json",
"bluetooth": {
"firmwareBackend": "bluetoothd",
"firmwareDaemonPresent": true,
"firmwareServiceRunning": false,
"vulpesAdapter": false,
"evidence": "tickets/evidence-2026-10-20/nokia-media-stack.txt"
}
}
}

View file

@ -0,0 +1,21 @@
/* SPDX-License-Identifier: MPL-2.0 */
(function () {
'use strict';
var pages = document.querySelectorAll('.page');
var current = 0;
function show(index) {
current = (index + pages.length) % pages.length;
for (var i = 0; i < pages.length; i++) {
pages[i].classList.toggle('active', i === current);
}
document.getElementById('position').textContent = (current + 1) + ' / ' + pages.length;
}
document.addEventListener('keydown', function (event) {
document.getElementById('last-key').textContent = event.key || String(event.keyCode);
if (event.key === 'ArrowRight' || event.key === 'Enter') {
event.preventDefault(); show(current + 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault(); show(current - 1);
}
});
}());

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vulpes — Diagnostic Nokia</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header><img src="icon.png" alt=""><div><h1>VULPES</h1><p>Laboratoire · Nokia 2720</p></div></header>
<main>
<section class="page active">
<span class="eyebrow">PREMIER CONTACT</span>
<h2>Bonjour, Nokia.</h2>
<p><strong>Gecko 153.5 ESR</strong> a calculé une page HTML/CSS/JS sur ce Nokia.</p>
<div class="notice">Cet écran utilise <strong>KaiOS / Gecko 48</strong>.<br>Le moteur récent est testé hors écran. Gaia reste à porter.</div>
</section>
<section class="page">
<span class="eyebrow">LE MATÉRIEL</span>
<h2>Petit format.</h2>
<dl><dt>Architecture</dt><dd>ARMv7 · 32 bits</dd><dt>Noyau</dt><dd>Linux 3.10.49</dd><dt>Mémoire visible</dt><dd>400 Mio environ</dd><dt>Écran intérieur</dt><dd>240 × 320</dd></dl>
</section>
<section class="page">
<span class="eyebrow">ESSAI DES TOUCHES</span>
<h2>À toi de jouer.</h2>
<p>Appuie sur les chiffres, les flèches ou la touche centrale.</p>
<output id="last-key">En attente…</output>
<p class="small">Aucune donnée personnelle lue. Aucune partition système modifiée.</p>
</section>
<section class="page proof">
<span class="eyebrow">RENDU RÉEL · GECKO 153.5 ESR</span>
<img src="gecko-proof.png" alt="Page calculée sur le Nokia : Gecko 153 et JavaScript moderne OK">
<p class="small">Image du rendu hors écran.<br>Cette prévisualisation reste dans KaiOS.</p>
</section>
</main>
<footer><span>← →</span><span id="position">1 / 4</span><span>OK : suite</span></footer>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,10 @@
{
"name": "Vulpes Diagnostic",
"description": "Essai d’affichage et des touches du Nokia 2720. Utilise le Gecko de KaiOS ; ce n’est pas Vulpes OS installé.",
"version": "0.2.0",
"launch_path": "/index.html",
"developer": {"name": "OverSu", "url": "https://vulpes-os.org"},
"icons": {"128": "/icon.png"},
"default_locale": "fr",
"orientation": "portrait"
}

View file

@ -0,0 +1,22 @@
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
body { background: #07182c; color: #edf6ff; font: 14px/1.35 sans-serif; }
header { display: flex; align-items: center; padding: 10px 12px; border-bottom: 1px solid #24405c; }
header img { width: 32px; height: 32px; margin-right: 9px; object-fit: contain; }
h1 { font-size: 19px; letter-spacing: 3px; margin: 0; }
header p { margin: 0; font-size: 10px; color: #a8c3db; }
main { padding: 12px; }
.page { display: none; }
.page.active { display: block; }
.eyebrow { color: #75c7ff; font-size: 10px; letter-spacing: 1px; }
h2 { margin: 6px 0 9px; font-size: 23px; line-height: 1.1; }
p { margin: 8px 0; }
.notice { padding: 9px; margin-top: 12px; background: #14304a; border-left: 3px solid #60baff; font-size: 12px; }
dl { margin: 8px 0; }
dt { color: #a8c3db; font-size: 10px; }
dd { margin: 0 0 2px; }
output { display: block; padding: 10px; border: 1px solid #60baff; border-radius: 5px; overflow-wrap: break-word; }
.small { font-size: 11px; color: #a8c3db; }
.proof { text-align: center; }
.proof img { display: block; width: 96px; height: 128px; margin: 6px auto; }
footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; padding: 6px 10px; background: #102b45; font-size: 11px; }

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
import os
from pathlib import Path
import subprocess
import time
os.umask(0o022)
with Path('/work/xorg-console.log').open('w') as log:
server = subprocess.Popen(['/usr/lib/xorg/Xorg', ':0', 'vt1',
'-config', '/etc/X11/xorg.conf', '-logfile', '/work/Xorg.log',
'-nolisten', 'tcp', '-ac', '-noreset', '-novtswitch', '-sharevts'],
stdout=log, stderr=subprocess.STDOUT)
try:
for attempt in range(40):
if server.poll() is not None:
raise RuntimeError('Xorg exited: ' + str(server.returncode))
if Path('/tmp/.X11-unix/X0').exists():
break
time.sleep(0.1)
with Path('/work/keypad.log').open('w') as keys, Path('/work/x11-test.log').open('w') as client_log:
keypad = subprocess.Popen(['/work/keypad'], stdout=keys, stderr=subprocess.STDOUT)
try:
client = subprocess.Popen(['/work/x11-test'], stdout=client_log, stderr=subprocess.STDOUT)
time.sleep(2)
subprocess.run(['/work/keypad','--self-test'],check=True)
print('X11 client exit:', client.wait(timeout=28), flush=True)
finally:
keypad.terminate()
keypad.wait(timeout=2)
finally:
server.terminate()
try:
server.wait(timeout=3)
except subprocess.TimeoutExpired:
server.kill()
server.wait()

View file

@ -0,0 +1,169 @@
import {tundraRadio} from 'resource://vulpes/host/Tundra.sys.mjs';
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
export function install() {
Services.obs.addObserver(function opened(win) {
win.addEventListener('load',()=>{
if(win.location.href==='chrome://vulpes/content/shell.xhtml') run(win).catch(Cu.reportError);
},{once:true});
},'chrome-document-global-created');
}
async function run(win) {
const report={engine:Services.appinfo.platformVersion,build:Services.appinfo.platformBuildID,started:Date.now()};
const save=()=>IOUtils.writeJSON('/work/gaia-audit.json',report);
try {
for(let i=0;i<600&&!win.hostReady;i++)await sleep(200);
if(!win.hostReady)throw Error('Host startup timeout');
ChromeUtils.registerWindowActor('VulpesProbe',{
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
allFrames:true,safeForUntrustedWebProcess:true,
});
const system=win.document.getElementById('system').browsingContext;
const find=(context,app)=>{
if(context.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return context;
for(const child of context.children){const result=find(child,app);if(result)return result;}
return null;
};
const evaluate=(app,script)=>find(system,app).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',script);
for(let i=0;i<300;i++) {
if(find(system,'homescreen') && await evaluate('homescreen',"return document.querySelectorAll('gaia-app-icon').length>0;"))break;
await sleep(200);
}
report.system=await evaluate('system','return {ready:!!window.applications?.ready,size:[innerWidth,innerHeight]};');
report.homescreen=await evaluate('homescreen',"return {icons:document.querySelectorAll('gaia-app-icon').length,size:[innerWidth,innerHeight],text:document.body.innerText.slice(0,300)};");
for(let i=0;i<60;i++) {
report.icons=await evaluate('homescreen',"return [...document.querySelectorAll('gaia-app-icon')].map(e=>({id:e.dataset.identifier,loaded:!!e._hasIcon,pending:!!e._image,images:[...(e._container?.querySelectorAll('img') || [])].map(i=>({width:i.naturalWidth,complete:i.complete}))}));");
report.logoHidden=await evaluate('system',"var e=document.getElementById('os-logo');return !e || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) || Number(getComputedStyle(e).opacity)<.05;");
if(report.logoHidden&&report.icons.length>=4&&report.icons.slice(0,4).every(icon=>icon.images.some(image=>image.complete&&image.width>0)))break;
await sleep(500);
}
report.homescreen.icons=report.icons.length;
report.compactUI=await evaluate('homescreen',"return {height:innerHeight,singlePanel:document.getElementById('panels').scrollWidth===document.getElementById('panels').clientWidth,indicatorsHidden:getComputedStyle(document.getElementById('page-indicator-header')).display==='none',pinnedPagesHidden:getComputedStyle(document.getElementById('pages-panel')).display==='none'};");
report.homeSearchHidden=await evaluate('system',"return getComputedStyle(document.querySelector('#homescreen > .chrome')).display==='none';");
report.layout=await evaluate('homescreen',"return {mode:document.documentElement.dataset.vulpesLayout,icons:[...document.querySelectorAll('gaia-app-icon')].slice(0,9).map(e=>({rect:e.getBoundingClientRect().toJSON(),image:e._container?.querySelector('img')?.getBoundingClientRect().toJSON()}))};");
report.hardware=await evaluate('system',"return await VulpesCompat.call('platform.snapshot',{});");
report.radioAPI=await evaluate('system',"return {slots:navigator.mozMobileConnections?.length || 0,battery:navigator.battery.level,signal:!!document.getElementById('statusbar-mobile-connection'),radioUnavailable:document.documentElement.hasAttribute('data-vulpes-radio-unavailable'),cards:navigator.mozIccManager.iccIds.map(id=>navigator.mozIccManager.getIccById(id).cardState),connections:[...navigator.mozMobileConnections].map(c=>({radioState:c.radioState,registered:c.voice.connected,type:c.voice.type,signal:c.voice.relSignalStrength}))};");
if(Services.env.get('VULPES_TUNDRA_RADIO')==='1') {
for(let attempt=0;attempt<30;attempt++) {
report.phoneBroker=await tundraRadio('status');
if(report.phoneBroker.ready) {
try {report.currentCallCount=(await tundraRadio('calls')).length;break;} catch (_) {}
}
await sleep(1000);
}
if(report.currentCallCount===undefined)throw Error('Phone broker did not become ready');
try {report.callAudio=await tundraRadio('callAudio',{settings:{operation:'status'}});}
catch(error) {report.callAudio={error:error.message};}
try { await tundraRadio('sms',{number:'INVALID',body:'validation only'});report.invalidSmsRejected=false; }
catch(error) { report.invalidSmsRejected=error.message==='INVALID_NUMBER'; }
}
report.readyMs=Date.now()-report.started;
const image=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
canvas.width=image.width;canvas.height=image.height;canvas.getContext('2d').drawImage(image,0,0);image.close();
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write('/work/gaia-native.png',new Uint8Array(await blob.arrayBuffer()));
await save();
if(Services.env.get("VULPES_TRIAL_MANUAL")==="1") {
let last='';
while(!win.closed) {
try {
const request=await IOUtils.readJSON('/work/manual-request.json');
if(typeof request.id==='string' && request.id!==last) {
last=request.id;
let response;
try {
if(request.app==='screenshot') {
const picture=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
canvas.getContext('2d').drawImage(picture,0,0);picture.close();
const capture=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write('/work/manual-screen.png',new Uint8Array(await capture.arrayBuffer()));
response={id:last,value:true};
} else response={id:last,value:await Promise.race([
evaluate(request.app,request.script),
sleep(30000).then(()=>{throw Error('Manual probe timed out');})
])};
} catch(error) {response={id:last,error:String(error)};}
await IOUtils.writeJSON('/work/manual-response.json',response,{tmpPath:'/work/manual-response.tmp'});
}
} catch (_) {}
await sleep(200);
}
return;
}
report.animation=await evaluate('homescreen',`return new Promise(resolve=>{let frames=0;const begin=performance.now();function tick(now){frames++;if(now-begin>=3000)resolve({frames,seconds:(now-begin)/1000,fps:frames*1000/(now-begin)});else requestAnimationFrame(tick);}requestAnimationFrame(tick);});`);
const until=async read=>{let value;for(let i=0;i<40;i++){value=await read();if(value)return value;await sleep(250);}return value;};
await evaluate('homescreen',"var first=document.querySelector('gaia-app-icon');first.setAttribute('tabindex','-1');first.focus();return document.activeElement===first;");
let sequence=0;
const key=async key=>{
const sent=++sequence;
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:sent,key},{tmpPath:'/work/keypad-request.json.tmp'});
for(let i=0;i<40;i++) {
try {if((await IOUtils.readJSON('/work/keypad-response.json')).sequence===sent)return;} catch (_) {}
await sleep(100);
}
throw Error('Keypad acknowledgement timed out');
};
report.inputPath='kernel evdev → keypad reader → XTest → GTK → host → Gaia';
await key('ArrowRight');
report.keypadSelection=await until(()=>evaluate('homescreen',"var id=document.querySelector('[data-vulpes-keypad-focus]')?.dataset.identifier;return id?.includes('sms.localhost') ? id : null;"));
if(!report.keypadSelection)throw Error('Right did not select Messages');
report.selectedAppName=await until(()=>evaluate('homescreen',"return document.getElementById('vulpes-compact-app-name')?.textContent==='Messages';"));
await key('Enter');
for(let i=0;i<150&&!find(system,'sms');i++)await sleep(200);
report.smsOpened=!!find(system,'sms');
if(report.smsOpened) {
await until(()=>evaluate('sms',"return document.readyState==='complete' && !!document.body;"));
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('sms.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
await evaluate('sms',"var input=document.createElement('input');input.id='vulpes-keypad-probe';input.type='tel';input.style='position:fixed;top:80px;left:0;z-index:99999';document.body.append(input);input.focus();return true;");
await key('2');
report.keypadInput=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value;"));
await evaluate('sms',"var input=document.getElementById('vulpes-keypad-probe');input.value='';input.type='text';input.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
report.multitap=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bob';"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#2#2'},{tmpPath:'/work/keypad-request.json.tmp'});
report.inputModes=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA2';"));
await key('Backspace');
report.deleteText=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA';"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#'},{tmpPath:'/work/keypad-request.json.tmp'});
await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-mode')?.textContent.startsWith('abc');"));
await evaluate('sms',"document.getElementById('vulpes-keypad-probe').remove();document.body.focus();return true;");
// Use the original SMS composer as well as the isolated editor probe.
await evaluate('sms',"var button=document.getElementById('threads-composer-link');button.focus();return true;");
await key('Enter');
if(!await until(()=>evaluate('sms',"return typeof ConversationView!=='undefined' && Navigation.isCurrentPanel('composer') && document.activeElement.classList.contains('recipient');")))throw Error('SMS composer did not open');
await evaluate('sms',"await App.whenReady();await new Promise(r=>setTimeout(r,400));ConversationView.recipients.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
if(!await until(()=>evaluate('sms',"return document.querySelector('#messages-recipients-list [contenteditable]')?.textContent==='5550100';")))throw Error('Recipient digits did not reach the original form');
await key('Enter');
report.smsRecipient=await until(()=>evaluate('sms',"return ConversationView.recipients.numbers.includes('5550100');"));
await evaluate('sms',"Compose.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
report.smsBody=await until(()=>evaluate('sms',"return document.getElementById('messages-input').textContent.trim()==='bob';"));
// Never send the synthetic message or retain it as a draft.
await evaluate('sms',"ConversationView.cleanFields();return true;");
await key('Escape');
await until(()=>evaluate('sms',"return !Navigation.isCurrentPanel('composer');"));
await key('Escape');
report.returnHome=await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('homescreen.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
}
if(report.returnHome) {
await evaluate('homescreen',"var icon=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.dataset.identifier.includes('communications'));icon.focus();return true;");
await key('Enter');
report.dialerOpened=await until(()=>Promise.resolve(!!find(system,'communications')));
if(report.dialerOpened) {
if(!await until(()=>evaluate('communications',"return performance.getEntriesByName('contentInteractive').length>0 && document.getElementById('phone-number-view')?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});")))throw Error('Dialer keypad did not become interactive');
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('communications.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
report.dialerDigits=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='5550100';"));
report.dialerObserved=await evaluate('communications',"return document.getElementById('phone-number-view')?.value;");
for(let i=0;i<7;i++){await key('Backspace');await sleep(150);}
report.dialerClear=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='';"));
await key('Escape');
}
}
}catch(error){report.error=String(error);report.stack=error.stack;}
await save();
}

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
import json
import os
import re
from pathlib import Path
import shutil
import subprocess
import time
os.umask(0o022)
for name in ('gaia-audit.json', 'gaia-native.png', 'gecko.log', 'gaia-server.log', 'memory-samples.jsonl', 'keypad-request.json', 'keypad-response.json', 'manual-request.json', 'manual-response.json'):
Path('/work',name).unlink(missing_ok=True)
ROOT = Path('/opt/vulpes')
PROFILE = Path('/work/gaia-profile')
PROFILE.mkdir(exist_ok=True)
os.chown(PROFILE,2000,2000)
prefs = {
'browser.shell.checkDefaultBrowser': False,
'browser.aboutwelcome.enabled': False,
'browser.startup.homepage_override.mstone': 'ignore',
'browser.dom.window.dump.enabled': True,
'datareporting.policy.dataSubmissionEnabled': False,
'app.update.auto': False,
'fission.autostart': False,
'dom.ipc.processCount': 1,
'dom.ipc.processPrelaunch.enabled': False,
'gfx.webrender.software': True,
'layers.acceleration.disabled': True,
'layout.css.devPixelsPerPx': '1.0',
'browser.cache.disk.enable': False,
'browser.cache.memory.capacity': 8192,
}
(PROFILE/'user.js').write_text('\n'.join('user_pref(%s,%s);'%(json.dumps(k),json.dumps(v)) for k,v in prefs.items()))
os.chown(PROFILE/'user.js',2000,2000)
os.chmod(PROFILE/'user.js',0o600)
saved = PROFILE/'vulpes-settings-v1.json'
if not saved.exists():
saved.write_text(json.dumps({'lockscreen.enabled':False,'vulpes.gaia-restoration.v1':True,'language.current':'fr'}))
os.chown(saved,2000,2000)
shutil.copy2(ROOT/'host/bootstrap/vulpes-autoconfig.js','/opt/firefox/defaults/pref/vulpes-autoconfig.js')
config=(ROOT/'host/bootstrap/vulpes.cfg').read_text()
config+="\nChromeUtils.importESModule('resource://vulpes/tests/BeatlesAudit.sys.mjs').install();\n"
Path('/opt/firefox/vulpes.cfg').write_text(config)
os.chmod('/opt/firefox/vulpes.cfg',0o644)
for directory in ('logs','profiles'):
(ROOT/directory).mkdir(exist_ok=True)
os.chown(ROOT/directory,2000,2000)
for path in (ROOT/'assets').iterdir():
if path.is_file():os.chown(path,2000,2000)
def unprivileged():
Path('/proc/self/oom_score_adj').write_text('800')
# Android's paranoid-network kernel requires inet even for private loopback.
os.setgroups([3003])
os.setgid(2000)
os.setuid(2000)
env=os.environ.copy()
env.update(HOME='/work',TMPDIR='/tmp',DISPLAY=':0',MOZ_ENABLE_WAYLAND='0',
VULPES_HOST_ROOT='/opt/firefox/vulpes/host',VULPES_WORKSPACE_ROOT='/opt/vulpes',
VULPES_FULLSCREEN='1',VULPES_KEYPAD='1',VULPES_NATIVE_TOUCH='1',
VULPES_DEVICE='beatles',VULPES_TUNDRA_STATUS='1',
MOZ_CRASHREPORTER_DISABLE='1',FONTCONFIG_PATH='/etc/fonts')
duration=1780 if os.environ.get("VULPES_TRIAL_MANUAL")=="1" else 160
processes=[]
handles=[]
try:
def start(command,log,drop=False):
path=Path('/work/'+log)
output=path.open('w');os.chmod(path,0o644);handles.append(output)
proc=subprocess.Popen(command,env=env,stdout=output,stderr=subprocess.STDOUT,
preexec_fn=unprivileged if drop else None)
processes.append(proc)
return proc
xorg=start(['/usr/lib/xorg/Xorg',':0','vt1','-config','/etc/X11/xorg.conf',
'-logfile','/work/Xorg.log','-nolisten','tcp','-ac','-noreset','-novtswitch','-sharevts','-nocursor'],'xorg-console.log')
for _ in range(100):
if xorg.poll() is not None:raise RuntimeError('Xorg failed')
if Path('/tmp/.X11-unix/X0').exists():break
time.sleep(.1)
keypad=start(['/work/keypad'],'keypad.log')
for _ in range(120):
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge failed; inspect keypad.log')
if 'Nokia keypad bridge ready' in Path('/work/keypad.log').read_text():break
time.sleep(.1)
else:raise RuntimeError('Physical keypad bridge startup timed out')
cover=start(['/usr/bin/python3.11','/work/cover-display.py'],'cover-display.log')
for _ in range(50):
if cover.poll() is not None:raise RuntimeError('External display failed; inspect cover-display.log')
if Path('/work/cover-ready').exists():break
time.sleep(.1)
else:raise RuntimeError('External display startup timed out')
server=start(['/usr/bin/python3.11',str(ROOT/'tools/serve-gaia.py')],'gaia-server.log',True)
import socket
for _ in range(100):
if server.poll() is not None:raise RuntimeError('Gaia server failed')
try:
with socket.create_connection(('127.0.0.1',8765),timeout=.2):break
except OSError:time.sleep(.1)
gecko=start(['/opt/firefox/firefox','-no-remote','-purgecaches','-profile',str(PROFILE),
'-chrome','chrome://vulpes/content/shell.xhtml'],'gecko.log',True)
begin=time.monotonic()
last_key_sequence=0
last_memory_sample=0
with Path('/work/memory-samples.jsonl').open('w') as memory:
while gecko.poll() is None and time.monotonic()-begin<duration:
if cover.poll() is not None:raise RuntimeError('External display stopped')
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge stopped')
request=Path('/work/keypad-request.json')
if request.exists():
command=json.loads(request.read_text())
sequence=command.get('sequence');processed=False
if (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
command.get('key') in ('ArrowRight','ArrowLeft','ArrowUp','ArrowDown','Backspace','Enter','2','Escape','EndCall','Notifications','AudioVolumeUp','AudioVolumeDown')):
subprocess.run(['/work/keypad','--inject-key',command['key']],env=env,check=True,timeout=5)
last_key_sequence=sequence;processed=True
elif (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
isinstance(command.get('keys'),str) and re.fullmatch(r'[0-9*#]{1,40}',command['keys'])):
subprocess.run(['/work/keypad','--inject-keys',command['keys']],env=env,check=True,timeout=10)
last_key_sequence=sequence;processed=True
if processed:
reply=Path('/work/keypad-response.tmp');reply.write_text(json.dumps({'sequence':sequence}));reply.replace('/work/keypad-response.json')
sample={'seconds':round(time.monotonic()-begin,1),'meminfo':Path('/proc/meminfo').read_text()}
if time.monotonic()-last_memory_sample>=2:
memory.write(json.dumps(sample)+'\n');memory.flush();last_memory_sample=time.monotonic()
time.sleep(.1)
print('Gecko exit:',gecko.poll(),flush=True)
finally:
for proc in reversed(processes):
if proc.poll() is None:proc.terminate()
for proc in reversed(processes):
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
for handle in handles:handle.close()

View file

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Vulpes ARMv7 Gecko test</title><style>*{box-sizing:border-box}body{margin:0;background:#071b2c;color:#edf6ff;font:16px sans-serif;padding:18px;width:240px}h1{font-size:24px;margin:4px 0 20px}small{color:#8ac9ff}.cards{display:grid;gap:12px}.card{padding:12px;background:#153347;border-left:3px solid #57db9d}p{font-size:13px;line-height:1.4}</style><small>VULPES · LABORATOIRE</small><h1>Gecko ARMv7</h1><div class="cards"><div class="card" id="engine">Chargement…</div><div class="card" id="js">JavaScript…</div></div><p>Page calculée sur le Nokia.<br>Essai hors écran, pas encore une interface interactive.</p><script>document.getElementById('engine').textContent='Gecko '+navigator.userAgent.match(/rv:([\d.]+)/)[1];const value={test:{ok:true}};document.getElementById('js').textContent=value?.test?.ok && 1n+2n===3n?'JavaScript moderne : OK':'Échec';</script>

View file

@ -0,0 +1,51 @@
# SPDX-License-Identifier: MPL-2.0
"""Fixed Nokia controls mounted writable only in the trusted broker namespace."""
import math
from pathlib import Path
import time
class HardwareControl:
def __init__(self,root=Path('/work/hardware-controls')):
self.root=root;self.original=None;self.pattern=[];self.next_at=0;self.on=False
def write(self,name,value):
(self.root/name).write_text(str(value)+'\n')
def request(self,value):
if not isinstance(value,dict):raise ValueError('INVALID_REQUEST')
op=value.get('operation')
if op=='screen':
level=value.get('level');enabled=value.get('enabled')
if type(enabled) is not bool or type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
raise ValueError('INVALID_REQUEST')
maximum=int((self.root/'backlight-max').read_text())
if not 1<=maximum<=65535:raise RuntimeError('NOT_SUPPORTED')
if self.original is None:self.original=int((self.root/'backlight').read_text())
brightness=max(1,int(level*maximum+.5)) if enabled else 0
self.write('backlight',brightness)
actual=int((self.root/'backlight').read_text())
if actual!=brightness:raise RuntimeError('HARDWARE_READBACK_FAILED')
return {'brightness':actual,'maximum':maximum}
if op=='vibrate':
pattern=value.get('pattern')
if not isinstance(pattern,list) or len(pattern)>32 or any(type(n) is not int or not 0<=n<=10000 for n in pattern) or sum(pattern)>10000:
raise ValueError('INVALID_REQUEST')
self.write('vibrator',0)
self.pattern=list(pattern);self.on=False;self.next_at=0;self.tick()
return True
raise ValueError('NOT_SUPPORTED')
def tick(self):
now=time.monotonic()
while self.pattern and now>=self.next_at:
duration=self.pattern.pop(0);self.on=not self.on
self.write('vibrator',duration if self.on else 0)
self.next_at=now+duration/1000
def restore(self):
self.pattern=[]
for name,value in [('vibrator',0),('backlight',self.original)]:
if value is not None:
try:self.write(name,value)
except OSError:pass

View file

@ -0,0 +1,331 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Nokia RIL control broker. Socket clients are Gecko's uid, never web content.
Parcel layouts follow Gecko 52 dom/system/gonk/ril_worker.js. The vendor
socket stays in Android's network namespace; Gecko only sees this broker.
"""
import copy
import hashlib
import json
import os
from pathlib import Path
import select
import signal
import socket
import struct
import subprocess
import sys
import time
import uuid
from call_audio import CallAudio
from stream_volume import StreamVolume
from hardware_control import HardwareControl
from radio_status import Radio, battery, REQUESTS
from sms_pdu import submit, deliver, number
# Fixed allowlist excludes every SIM PIN/PUK, identity and provisioning request.
CONTROL_REQUESTS={9,10,12,23,25,37,40}
SOCKET='/work/phone-control.sock'
STORE=Path('/work/phone-private/inbox.json')
def integer(*values):return struct.pack('<'+'i'*len(values),*values)
def string(value):
if value is None:return integer(-1)
raw=value.encode('utf-16-le');body=integer(len(raw)//2)+raw+b'\0\0'
return body+b'\0'*((-len(body))%4)
def current_calls(parcel):
count=parcel.integer() if parcel.offset<len(parcel.data) else 0
if not 0<=count<=16:raise ValueError('INVALID_CALL_LIST')
rows=[]
for _ in range(count):
state,index,toa,multi,incoming,als,voice,privacy=[parcel.integer() for _ in range(8)]
num=parcel.string() or '';presentation=parcel.integer()
parcel.string();parcel.integer();uus=parcel.integer()
if uus:raise ValueError('UNSUPPORTED_CALL_UUS')
if state not in range(6) or not 1<=index<=255:raise ValueError('INVALID_CALL_LIST')
if voice:
if presentation!=0:num=''
elif toa==145 and num and not num.startswith('+'):num='+'+num
rows.append({'index':index,'state':('active','held','dialing','alerting','incoming','waiting')[state],
'number':num[:80],'direction':'incoming' if incoming else 'outgoing'})
return rows
class Inbox:
def __init__(self,path=STORE):
self.path=path
self.value=json.loads(path.read_text()) if path.exists() else {'inbox':[],'parts':{},'seen':{}}
def save(self):
self.path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
self.path.parent.chmod(0o700)
temporary=self.path.with_suffix('.tmp')
with open(temporary,'w',opener=lambda p,f:os.open(p,f,0o600)) as stream:
os.fchmod(stream.fileno(),0o600);json.dump(self.value,stream)
stream.flush();os.fsync(stream.fileno())
temporary.replace(self.path)
fd=os.open(self.path.parent,os.O_DIRECTORY)
try:os.fsync(fd)
finally:os.close(fd)
def receive(self,pdu):
previous=copy.deepcopy(self.value)
try:self._receive(pdu)
except Exception:
self.value=previous;raise
def _receive(self,pdu):
row=deliver(pdu);key=hashlib.sha256(pdu.upper().encode()).hexdigest();now=time.time()
if key in self.value['seen']:return
if len(self.value['inbox'])>=500:raise ValueError('SMS_STORE_FULL')
self.value['seen']={k:v for k,v in self.value['seen'].items() if now-v<7*86400}
self.value['parts']={k:v for k,v in self.value['parts'].items() if now-v['created']<86400}
concat=row.pop('concat')
if concat:
ref,total,part=concat
group=hashlib.sha256((row['sender']+':'+str(ref)+':'+str(total)).encode()).hexdigest()
if group not in self.value['parts'] and len(self.value['parts'])>=100:raise ValueError('SMS_STORE_FULL')
record=self.value['parts'].setdefault(group,{'created':now,'pieces':{}})
record['pieces'][str(part)]=row['body']
if len(record['pieces'])==total:
row['body']=''.join(record['pieces'][str(i)] for i in range(1,total+1))
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
del self.value['parts'][group]
else:
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
self.value['seen'][key]=now;self.save()
def acknowledge(self,ids):
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)!=36 for i in ids):raise ValueError('INVALID_REQUEST')
previous=copy.deepcopy(self.value)
try:
self.value['inbox']=[r for r in self.value['inbox'] if r['receipt'] not in ids];self.save()
except Exception:
self.value=previous;raise
return True
class ControlRadio(Radio):
def __init__(self,path,slot,inbox):
super().__init__(path,slot);self.inbox=inbox;self.result=None;self.waiting=None
def send(self,request,payload=b''):
if request not in CONTROL_REQUESTS:raise ValueError('RADIO_INVALID_REQUEST')
if not self.sock:raise RuntimeError('RADIO_NOT_READY')
self.serial+=1;body=integer(request,self.serial)+payload
self.sock.sendall(struct.pack('>I',len(body))+body)
self.pending[self.serial]=(request,time.monotonic())
return self.serial
def response(self,request,error,parcel,serial=None):
if request in REQUESTS:return super().response(request,error,parcel)
if request==37:return
if (request,serial)!=self.waiting:return
if error:self.result=(False,'RADIO_REQUEST_FAILED');return
if request==9:
try:result=current_calls(parcel)
except (ValueError,UnicodeError) as error:
print('Call list decode:',str(error),'offset',parcel.offset,'length',len(parcel.data),flush=True)
self.result=(False,'RADIO_INVALID_CALL_LIST');return
elif request==25:
parcel.integer();parcel.string();parcel.integer()
result={'state':'sent'}
else:result=True
self.result=(True,result)
def unsolicited(self,event,parcel):
if event==1003 and self.slot==0:
try:
self.inbox.receive(parcel.string());success,cause=1,0
except (OSError,ValueError,UnicodeError):
# Do not acknowledge success until the message is durably stored.
print('Incoming SMS not stored',flush=True);success,cause=0,255
self.send(37,integer(2,success,cause))
else:super().unsolicited(event,parcel)
class Broker:
def __init__(self,radios,inbox,audio=None,volume=None,hardware=None):
self.radios=radios;self.inbox=inbox;self.session=uuid.uuid4().hex;self.published=0;self.audio=audio;self.volume=volume;self.hardware=hardware;self.original_power={}
def step(self,timeout=.1):
for radio in self.radios:
try:radio.poll()
except (OSError,ValueError):radio.close()
sockets={r.sock:r for r in self.radios if r.sock}
ready,_,_=select.select(list(sockets),[],[],timeout)
for sock in ready:
try:sockets[sock].receive()
except (OSError,ValueError,UnicodeError) as error:
print('RIL receive:',type(error).__name__,str(error),flush=True);sockets[sock].close()
if time.monotonic()-self.published>=1:
dest=Path('/run/tundra-hardware/status.json');dest.parent.mkdir(exist_ok=True,parents=True)
try:wifi=json.loads(Path('/run/tundra-wifi/state.json').read_text())
except (OSError,ValueError):wifi={'available':False,'state':'unavailable'}
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
'wifi':wifi,'radio':[r.state for r in self.radios]}
temp=dest.with_suffix('.tmp');temp.write_text(json.dumps(value));temp.chmod(0o644);temp.replace(dest)
self.published=time.monotonic()
def exchange(self,request,payload=b'',timeout=12,slot=0):
radio=self.radios[slot]
# A UI timeout does not cancel the request in the vendor daemon. Do not
# enqueue more copies while waiting for its reply or transport recovery.
if any(kind==request for kind,_ in radio.pending.values()):
raise RuntimeError('RADIO_BUSY')
if request==9:timeout=min(timeout,3)
radio.result=None;radio.waiting=None
try:
radio.waiting=(request,radio.send(request,payload));until=time.monotonic()+timeout
while radio.result is None and time.monotonic()<until:
self.step()
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
if radio.result is None:raise RuntimeError('RADIO_TIMEOUT')
success,value=radio.result
if not success:raise RuntimeError(value)
return value
finally:radio.waiting=None
def radio_power(self,slot,enabled,remember=True):
if type(slot) is not int or slot not in range(len(self.radios)) or type(enabled) is not bool:
raise ValueError('INVALID_REQUEST')
radio=self.radios[slot]
before=radio.state.get('radioState')
if before not in ('enabled','disabled'):raise RuntimeError('RADIO_NOT_READY')
target='enabled' if enabled else 'disabled'
if before==target:return {'slot':slot,'radioState':target}
# Do not interrupt an ongoing or incoming call when changing radio power.
if not enabled and self.radios[0].state.get('radioState')=='enabled' and self.calls():
raise RuntimeError('RADIO_BUSY')
if remember:self.original_power.setdefault(slot,before=='enabled')
self.exchange(23,integer(1,int(enabled)),slot=slot)
deadline=time.monotonic()+8
while radio.state.get('radioState')!=target and time.monotonic()<deadline:
self.step()
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
if radio.state.get('radioState')!=target:raise RuntimeError('RADIO_READBACK_FAILED')
radio.last_poll=0
return {'slot':slot,'radioState':target}
def restore_power(self):
for slot,enabled in self.original_power.items():
try:self.radio_power(slot,enabled,remember=False)
except (OSError,RuntimeError,ValueError) as error:
print('Radio power restore failed:',slot,str(error),flush=True)
def calls(self):
rows=[{**row,'path':'/beatles/'+self.session+'/call/'+str(row['index'])} for row in self.exchange(9)]
if self.audio:self.audio.update(rows)
return rows
def request(self,value):
if not isinstance(value,dict) or value.get('version')!=1:raise ValueError('INVALID_REQUEST')
op=value.get('operation')
if op=='status':return {'ready':self.radios[0].state.get('available',False),'voiceAudio':bool(self.audio and self.audio.available)}
if op=='radioPower':return self.radio_power(value.get('slot'),value.get('enabled'))
if op=='hardware':
if not self.hardware:raise RuntimeError('NOT_SUPPORTED')
return self.hardware.request(value.get('settings'))
if op=='volumeState':
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.volume.state()
if op=='volume':
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.volume.request(value)
if op=='callAudio':
if not self.audio:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.audio.request(value.get('settings',{}))
if op=='incomingMessages':return self.inbox.value['inbox'][:10]
if op=='ackMessages':return self.inbox.acknowledge(value.get('ids'))
if op=='calls':return self.calls()
if op in ('dial','sms'):
destination=number(value.get('number'))
if not self.radios[0].state.get('voice',{}).get('connected'):raise RuntimeError('RADIO_NOT_REGISTERED')
if op=='sms':return self.exchange(25,integer(2)+string(None)+string(submit(destination,value.get('body'))))
before={row['path'] for row in self.calls()}
if before:raise RuntimeError('RADIO_BUSY')
self.exchange(10,string(destination)+integer(0,0,0))
for _ in range(30):
for row in self.calls():
if row['path'] not in before and row['direction']=='outgoing':return {'path':row['path'],'startedAt':time.time()}
self.step(.1)
raise RuntimeError('RADIO_TIMEOUT')
if op in ('hangup','answer'):
path=value.get('path');call=next((r for r in self.calls() if r['path']==path),None)
if not call:raise ValueError('INVALID_CALL')
if op=='answer':
if call['state'] not in ('incoming','waiting'):raise ValueError('INVALID_CALL')
self.exchange(40)
else:self.exchange(12,integer(1,call['index']))
return {'path':path}
raise ValueError('NOT_SUPPORTED')
def read_request(conn):
data=b''
while b'\n' not in data:
chunk=conn.recv(4096)
if not chunk:break
data+=chunk
if len(data)>8192:raise ValueError('INVALID_REQUEST')
return json.loads(data)
def serve():
os.umask(0o022);inbox=Inbox()
radios=[ControlRadio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,i,inbox) for i,name in enumerate(('rild','rild2'))]
audio=CallAudio();volume=StreamVolume();hardware=HardwareControl();broker=Broker(radios,inbox,audio,volume,hardware);running=True
def stop(*_):
nonlocal running
running=False
signal.signal(signal.SIGTERM,stop)
wifi_log=open('/work/wifi-control.log','w')
wifi_process=subprocess.Popen([sys.executable,'/work/wifi_control.py'],stdout=wifi_log,stderr=wifi_log)
path=Path(SOCKET);path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.setblocking(False)
print('Nokia phone broker ready; slot 0; Android 6 audio adapter',flush=True)
try:
while running:
broker.step()
try:hardware.tick()
except OSError:
hardware.pattern=[]
print('Nokia vibration control unavailable',flush=True)
if not select.select([server],[],[],0)[0]:continue
conn,_=server.accept()
with conn:
conn.settimeout(2)
pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,2000):continue
try:response={'ok':True,'result':broker.request(read_request(conn))}
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
allowed={'INVALID_REQUEST','INVALID_NUMBER','INVALID_SMS','SMS_TOO_LONG','INVALID_CALL','RADIO_NOT_REGISTERED','RADIO_NOT_READY','RADIO_BUSY','RADIO_REQUEST_FAILED','RADIO_READBACK_FAILED','RADIO_INVALID_CALL_LIST','RADIO_TIMEOUT','NOT_SUPPORTED','CALL_AUDIO_UNAVAILABLE','HARDWARE_READBACK_FAILED'}
message=str(error);response={'ok':False,'error':message if message in allowed else 'RADIO_REQUEST_FAILED'}
try:conn.sendall(json.dumps(response).encode()+b'\n')
except OSError:pass
finally:
path.unlink(missing_ok=True)
broker.restore_power()
wifi_process.terminate()
try:wifi_process.wait(timeout=5)
except subprocess.TimeoutExpired:wifi_process.kill();wifi_process.wait()
wifi_log.close()
hardware.restore()
volume.restore()
audio.restore()
for radio in radios:radio.close()
def client():
raw=sys.stdin.buffer.read(8193)
if len(raw)>8192:raise ValueError('INVALID_REQUEST')
value=json.loads(raw)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(25);conn.connect(SOCKET)
conn.sendall(json.dumps(value).encode()+b'\n')
response=b''
while b'\n' not in response:
chunk=conn.recv(8192)
if not chunk:raise RuntimeError('RADIO_INVALID_REPLY')
response+=chunk
if len(response)>65536:raise RuntimeError('RADIO_INVALID_REPLY')
sys.stdout.write(response.decode())
if __name__=='__main__':
if sys.argv[1:]==['--client-envelope']:client()
elif not sys.argv[1:]:serve()
else:raise SystemExit('Unsupported arguments')

View file

@ -0,0 +1,201 @@
# SPDX-License-Identifier: MPL-2.0
"""Read-only Android RIL status for beatles; no PIN, identity, SMS or call requests."""
import json
import os
from pathlib import Path
import select
import socket
import struct
import time
REQUESTS = (1, 19, 20, 21, 22) # SIM status, signal, voice/data registration, operator.
TECHNOLOGIES = {1:'gprs',2:'edge',3:'umts',4:'is95a',5:'is95b',6:'1xrtt',
7:'evdo0',8:'evdoa',9:'hsdpa',10:'hsupa',11:'hspa',12:'evdob',
13:'ehrpd',14:'lte',15:'hspa+',16:'gsm'}
class Parcel:
def __init__(self, data):
self.data, self.offset = data, 0
def integer(self):
if self.offset + 4 > len(self.data): raise ValueError('TRUNCATED_PARCEL')
value, = struct.unpack_from('<i', self.data, self.offset)
self.offset += 4
return value
def string(self):
length = self.integer()
if length == -1: return None
if not 0 <= length <= 4096: raise ValueError('INVALID_STRING_LENGTH')
end = self.offset + length * 2
if end + 2 > len(self.data): raise ValueError('TRUNCATED_STRING')
if self.data[end:end+2] != b'\0\0': raise ValueError('INVALID_TERMINATOR')
value = self.data[self.offset:end].decode('utf-16-le')
self.offset = (end + 2 + 3) & ~3
if self.offset > len(self.data): raise ValueError('TRUNCATED_PADDING')
return value
def strings(self):
count = self.integer()
if not 0 <= count <= 32: raise ValueError('INVALID_ARRAY_LENGTH')
return [self.string() for _ in range(count)]
def registration(values):
state = int(values[0]) if values and values[0] else -1
tech = int(values[3]) if len(values)>3 and values[3] else 0
return {'state':{0:'notSearching',1:'registered',2:'searching',3:'denied',
4:'unknown',5:'registered',10:'notSearching',12:'searching',
13:'denied',14:'unknown'}.get(state,'unknown'),
'connected':state in (1,5), 'roaming':state == 5,
'emergencyCallsOnly':state in (10,12,13,14), 'type':TECHNOLOGIES.get(tech)}
def decode(request, parcel):
if request == 1:
card = parcel.integer(); parcel.integer()
gsm = parcel.integer(); parcel.integer(); parcel.integer()
count = parcel.integer()
if not 0 <= count <= 8: raise ValueError('INVALID_SIM_APPLICATION_COUNT')
app_state = None
for index in range(count):
parcel.integer(); state=parcel.integer(); parcel.integer()
parcel.string(); parcel.string() # AID/label are deliberately discarded.
parcel.integer(); parcel.integer(); parcel.integer()
if index == gsm: app_state=state
return {'sim':{'present':card == 1, 'cardState':
'absent' if card == 0 else {2:'pinRequired',3:'pukRequired',4:'networkLocked',5:'ready'}.get(app_state,'unknown')}}
if request in (20,21):
state=registration(parcel.strings())
# Packet-network registration does not prove that a data bearer is open.
if request == 21: state['connected']=False
return {('voice' if request == 20 else 'data'):state}
if request == 22:
values = parcel.strings()
return {'operator':{'longName':(values[0] or '')[:100] if values else '',
'shortName':(values[1] or '')[:100] if len(values)>1 else ''}}
if request == 19:
# GSM ASU can be unknown on LTE. RIL v6+ stores positive LTE RSRP
# at index 8; normalize its valid 44..140 dBm magnitude for Gaia.
values=[]
while parcel.offset + 4 <= len(parcel.data): values.append(parcel.integer())
if not values: raise ValueError('TRUNCATED_SIGNAL')
asu=values[0]
rsrp=values[8] if len(values)>8 else None
if rsrp is not None and 44<=rsrp<=140:
return {'signal':round((140-rsrp)*100/96)}
return {'signal':round(asu*100/31) if 0<=asu<=31 else None}
raise ValueError('READ_ONLY_REQUEST_REQUIRED')
class Radio:
def __init__(self, path, slot):
self.path,self.slot=path,slot
self.sock=None;self.buffer=b'';self.pending={};self.serial=0
self.last_poll=0;self.retry_at=0;self.retry_delay=5
self.state={'slot':slot,'available':False,'radioState':'unknown'}
def close(self):
if self.sock:self.sock.close()
self.sock=None;self.buffer=b'';self.pending={}
self.state={'slot':self.slot,'available':False,'radioState':'unknown'}
self.retry_at=time.monotonic()+self.retry_delay
self.retry_delay=min(60,self.retry_delay*2)
def connect(self):
connection=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
try:
connection.settimeout(1);connection.connect(self.path)
connection.setblocking(False);self.sock=connection;self.last_poll=0
print('RIL slot',self.slot,'connected',flush=True)
except OSError as error:
print('RIL slot',self.slot,'connect errno',error.errno,flush=True)
connection.close();self.close()
def poll(self):
now=time.monotonic()
if not self.sock:
if now>=self.retry_at:self.connect()
return
# CS fallback and network handover can outlast an individual UI request.
# Keep the transport open for late replies instead of repeatedly filling
# the vendor socket's accept queue with abandoned reconnects.
if any(now-created>45 for _,created in self.pending.values()):
print('RIL slot',self.slot,'request timeout; kinds',sorted({kind for kind,_ in self.pending.values()}),flush=True)
self.close();return
if now-self.last_poll<5:return
self.last_poll=now
for request in REQUESTS:
if any(kind==request for kind,_ in self.pending.values()):continue
self.serial+=1
body=struct.pack('<ii',request,self.serial)
self.sock.sendall(struct.pack('>I',len(body))+body)
self.pending[self.serial]=(request,now)
def receive(self):
data=self.sock.recv(65536)
if not data:raise OSError('RIL_DISCONNECTED')
self.buffer+=data
while len(self.buffer)>=4:
length,=struct.unpack_from('>I',self.buffer)
if not 4<=length<=65536:raise ValueError('INVALID_FRAME_LENGTH')
if len(self.buffer)<length+4:return
parcel=Parcel(self.buffer[4:4+length]);self.buffer=self.buffer[4+length:]
kind=parcel.integer()
if kind == 0:
serial,error=parcel.integer(),parcel.integer()
pending=self.pending.pop(serial,None)
if pending is None:continue
request,_=pending
self.retry_delay=5
self.response(request,error,parcel,serial)
elif kind == 1:
self.unsolicited(parcel.integer(),parcel)
def response(self,request,error,parcel,serial=None):
self.state['available']=True
if error:
self.state.setdefault('errors',{})[str(request)]=error
self.state.pop({1:'sim',19:'signal',20:'voice',21:'data',22:'operator'}[request],None)
return
self.state.setdefault('errors',{}).pop(str(request),None)
self.state.update(decode(request,parcel))
def unsolicited(self,event,parcel):
if event == 1000:
self.state['radioState']={0:'disabled',1:'unavailable',10:'enabled'}.get(parcel.integer(),'unknown')
elif event == 1034:
count=parcel.integer()
if count==1:self.state['rilVersion']=parcel.integer()
elif event in (1002,1009,1019):self.last_poll=0
# The read-only adapter never consumes or acknowledges incoming SMS.
def battery(root=Path('/sys/class/power_supply/battery')):
try:
level=int((root/'capacity').read_text().strip())
status=(root/'status').read_text().strip()
if not 0<=level<=100 or status not in ('Charging','Full','Discharging','Not charging'):return None
return {'level':level/100,'charging':status in ('Charging','Full')}
except (OSError,ValueError):return None
def main():
os.umask(0o022)
destination=Path('/run/tundra-hardware/status.json')
destination.parent.mkdir(parents=True,exist_ok=True)
destination.parent.chmod(0o755)
radios=[Radio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,index) for index,name in enumerate(('rild','rild2'))]
published=0
while True:
for radio in radios:
try:radio.poll()
except (OSError,ValueError) as error:
print('RIL slot',radio.slot,'poll',type(error).__name__,getattr(error,'errno',None),flush=True);radio.close()
active={r.sock:r for r in radios if r.sock}
readable,_,_=select.select(list(active),[],[],.25)
for sock in readable:
try:active[sock].receive()
except (OSError,ValueError,UnicodeError) as error:
print('RIL slot',active[sock].slot,'receive',type(error).__name__,getattr(error,'errno',None),flush=True);active[sock].close()
if time.monotonic()-published<1:continue
published=time.monotonic()
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
'wifi':{'available':False,'state':'unavailable'},'radio':[r.state for r in radios]}
temporary=destination.with_suffix('.tmp')
temporary.write_text(json.dumps(value)+'\n');temporary.chmod(0o644);temporary.replace(destination)
if __name__=='__main__':main()

View file

@ -0,0 +1,16 @@
#!/system/bin/sh
BASE=/data/local/tmp/vulpes-gecko-armhf
case "$2" in 156.0.1) BASE=/data/local/vulpes-beatles;; ""|153.5.0esr) ;; *) exit 2;; esac
MODE="$1"
case "$MODE" in version|render) ;; *) exit 2;; esac
[ "$MODE" != render ] || /system/bin/busybox rm -f "$BASE/rootfs/work/gecko.png"
/system/bin/busybox mkdir -p "$BASE/rootfs/proc" "$BASE/rootfs/dev/shm" "$BASE/rootfs/work/profile" "$BASE/rootfs/tmp"
/system/bin/busybox chown -R 2000:2000 "$BASE/rootfs/work" "$BASE/rootfs/tmp"
/system/bin/busybox timeout -t 60 -s KILL "$BASE/gecko-probe-launcher" "$MODE" > "$BASE/$MODE.log" 2>&1
CODE=$?
# Only the disposable runtime's processes; KaiOS and ADB use another root.
for PROC in /proc/[0-9]*; do
[ "$(/system/bin/busybox readlink "$PROC/root")" != "$BASE/rootfs" ] || kill -KILL "${PROC##*/}"
done
echo "$CODE" > "$BASE/$MODE.exit"
/system/bin/busybox chmod 644 "$BASE/$MODE.log" "$BASE/$MODE.exit"

View file

@ -0,0 +1,116 @@
# SPDX-License-Identifier: MPL-2.0
"""GSM SMS transport for the Nokia RIL (3GPP TS 23.040/23.038).
Default alphabet and extension characters match Gecko's GsmPDUHelper.
No binary messages, SIM commands or national-language shift tables are accepted.
"""
import datetime
import re
GSM='@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
EXT={10:'\f',20:'^',40:'{',41:'}',47:'\\',60:'[',61:'~',62:']',64:'|',101:'€'}
def number(value):
if not isinstance(value,str) or not re.fullmatch(r'\+?[0-9]{3,20}',value):raise ValueError('INVALID_NUMBER')
return value
def septets(text):
values=[]
for char in text:
if char in GSM and char!='\x1b':values.append(GSM.index(char))
elif char in EXT.values():values.extend((27,next(k for k,v in EXT.items() if v==char)))
else:return None
return values
def pack7(values):
packed=bytearray((len(values)*7+7)//8)
for i,value in enumerate(values):
bit=i*7;packed[bit//8]|=(value<<(bit%8))&255
if bit%8>1:packed[bit//8+1]|=value>>(8-bit%8)
return bytes(packed)
def unpack7(data,count,start=0):
if (start+count)*7>len(data)*8:raise ValueError('TRUNCATED_SMS')
result=[];escape=False
for i in range(start,start+count):
bit=i*7;value=(int.from_bytes(data[bit//8:bit//8+2],'little')>>(bit%8))&127
if escape:result.append(EXT.get(value,'<EFBFBD>'));escape=False
elif value==27:escape=True
else:result.append(GSM[value])
if escape:raise ValueError('TRUNCATED_SMS_ESCAPE')
return ''.join(result)
def submit(destination,text):
number(destination)
if not isinstance(text,str) or not text or '\x00' in text:raise ValueError('INVALID_SMS')
values=septets(text)
if values is not None:
if len(values)>160:raise ValueError('SMS_TOO_LONG')
payload=pack7(values);dcs=0;length=len(values)
else:
payload=text.encode('utf-16-be');dcs=8;length=len(payload)
if length>140:raise ValueError('SMS_TOO_LONG')
digits=destination.lstrip('+');padded=digits+('F' if len(digits)%2 else '')
address=bytes(int(padded[i+1]+padded[i],16) for i in range(0,len(padded),2))
# SMS-SUBMIT, no status-report request; SMSC is supplied separately by RIL.
return (bytes((1,0,len(digits),145 if destination.startswith('+') else 129))+
address+bytes((0,dcs,length))+payload).hex().upper()
def deliver(hexadecimal):
if not isinstance(hexadecimal,str) or len(hexadecimal)>1024:raise ValueError('INVALID_SMS_PDU')
try:data=bytes.fromhex(hexadecimal)
except ValueError:raise ValueError('INVALID_SMS_PDU') from None
pos=0
def take(n):
nonlocal pos
if n<0 or pos+n>len(data):raise ValueError('TRUNCATED_SMS')
part=data[pos:pos+n];pos+=n;return part
smsc=take(1)[0];take(smsc)
flags=take(1)[0]
if flags&3:raise ValueError('UNSUPPORTED_SMS_TYPE')
digits,toa=take(2);raw=take((digits+1)//2)
if toa&0x70==0x50:sender=unpack7(raw,digits*4//7)
else:
sender=''.join(format(b&15,'x')+format(b>>4,'x') for b in raw)[:digits]
if not sender.isdigit():raise ValueError('INVALID_SMS_SENDER')
if toa&0x70==0x10:sender='+'+sender
pid,dcs=take(2)
# Reject SIM data download / class-2 / compressed payloads rather than consuming them.
if pid or dcs not in (0,8):raise ValueError('UNSUPPORTED_SMS_ENCODING')
stamp=take(7)
def decimal(b):
lo,hi=b&15,b>>4
if lo>9 or hi>9:raise ValueError('INVALID_SMS_DATE')
return lo*10+hi
year,month,day,hour,minute,second=map(decimal,stamp[:6])
quarters=decimal(stamp[6]&0xf7)*(-1 if stamp[6]&8 else 1)
if abs(quarters)>56:raise ValueError('INVALID_SMS_DATE')
date=datetime.datetime(2000+year,month,day,hour,minute,second,
tzinfo=datetime.timezone(datetime.timedelta(minutes=quarters*15)))
length=take(1)[0];payload=take((length*7+7)//8 if dcs==0 else length)
header=0;concat=None
if flags&64:
if not payload:raise ValueError('TRUNCATED_SMS_HEADER')
header=payload[0]+1
if header>len(payload):raise ValueError('TRUNCATED_SMS_HEADER')
cursor=1
while cursor<header:
if cursor+2>header:raise ValueError('TRUNCATED_SMS_HEADER')
kind,size=payload[cursor:cursor+2];cursor+=2
part=payload[cursor:cursor+size];cursor+=size
if cursor>header:raise ValueError('TRUNCATED_SMS_HEADER')
if kind==0 and size==3:concat=(part[0],part[1],part[2])
elif kind==8 and size==4:concat=(part[0]*256+part[1],part[2],part[3])
else:raise ValueError('UNSUPPORTED_SMS_HEADER')
if concat and not 1<=concat[2]<=concat[1]<=10:raise ValueError('INVALID_SMS_PART')
if dcs==0:
skip=(header*8+6)//7
body=unpack7(payload,length-skip,skip)
else:body=payload[header:].decode('utf-16-be')
return {'sender':sender,'body':body,'timestamp':round(date.timestamp()*1000),'concat':concat}

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: MPL-2.0
"""Stream volume for the Nokia Android 6 audio policy service.
AOSP android-6.0.1_r1 IAudioPolicyService: setStreamVolumeIndex=16,
getStreamVolumeIndex=17 (index, status), getDevicesForStream=24.
The firmware reports 0..5 for voice, 0..15 for the other streams below.
Only the currently routed outputs are changed; trial shutdown restores them.
"""
import math
import re
import subprocess
STREAMS={'telephony':(0,5),'notification':(5,15),'content':(3,15),'alarm':(4,15),'bt_sco':(6,15)}
class StreamVolume:
def __init__(self):
self.original={}
def transact(self,method,*values):
command=['/system/bin/service','call','media.audio_policy',str(method)]
for value in values:command.extend(('i32',str(value)))
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
if result.returncode:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
# service prints either one line or address-prefixed Parcel rows.
text=result.stdout
if not text.startswith('Result: Parcel('):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
text=re.sub(r"'[^']*'",'',text)
text=re.sub(r'0x[0-9a-fA-F]+:', '',text)
return [int(word,16) for word in re.findall(r'\b[0-9a-fA-F]{8}\b',text)]
def get(self,stream,device,maximum):
reply=self.transact(17,stream,device)
if len(reply)!=2 or reply[1]!=0 or not 0<=reply[0]<=maximum:
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return reply[0]
def state(self):
result={}
for name,(stream,maximum) in STREAMS.items():
reply=self.transact(24,stream)
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
mask=reply[0] or 0x40000000
device=mask&-mask
result[name]=self.get(stream,device,maximum)/maximum
return result
def request(self,value):
if not isinstance(value,dict) or value.get('stream') not in STREAMS:
raise ValueError('INVALID_REQUEST')
level=value.get('level')
if type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
raise ValueError('INVALID_REQUEST')
stream,maximum=STREAMS[value['stream']]
reply=self.transact(24,stream)
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
mask=reply[0] or 0x40000000
devices=[1<<bit for bit in range(31) if mask&(1<<bit)]
index=int(level*maximum+.5)
for device in devices:
old=self.get(stream,device,maximum)
self.original.setdefault((stream,device),old)
if self.transact(16,stream,index,device)!=[0] or self.get(stream,device,maximum)!=index:
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return {'stream':value['stream'],'index':index,'maximum':maximum,'devices':devices}
def restore(self):
for (stream,device),index in self.original.items():
try:self.transact(16,stream,index,device)
except (OSError,RuntimeError,subprocess.SubprocessError):pass

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,334 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Nokia Wi-Fi adapter: vendor supplicant, private client socket, no credentials in argv."""
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import socket
import struct
import subprocess
import sys
import time
import uuid
SOCKET = '/work/wifi-control.sock'
STATE = Path('/run/tundra-wifi/state.json')
WPA = '/work/radio-sockets/wpa_wlan0'
ERRORS = {'WIFI_UNAVAILABLE', 'WIFI_DISABLED', 'WIFI_INVALID_REQUEST', 'WIFI_INVALID_PASSWORD',
'WIFI_UNSUPPORTED_SECURITY', 'WIFI_CONNECTION_FAILED', 'WIFI_DHCP_FAILED', 'WIFI_SAVE_FAILED', 'NOT_SUPPORTED'}
def decode_ssid(text):
raw = bytearray()
for part in re.findall(r'\\x[0-9a-fA-F]{2}|\\.|[^\\]+|\\$', text):
if re.fullmatch(r'\\x[0-9a-fA-F]{2}', part): raw.append(int(part[2:], 16))
elif part.startswith('\\'): raw.extend({'\\n':b'\n','\\r':b'\r','\\t':b'\t','\\\\':b'\\','\\"':b'"'}.get(part,part.encode()))
else: raw.extend(part.encode())
return raw.decode('utf-8', errors='replace')
def security(flags):
if 'EAP' in flags or 'WEP' in flags: return 'unsupported'
if 'PSK' in flags: return 'wpa-psk'
if 'SAE' in flags: return 'unsupported'
return 'open'
def parse_scan(text):
rows=[]
for line in text.splitlines()[1:]:
parts=line.split('\t',4)
if len(parts)!=5 or not re.fullmatch(r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',parts[0]): continue
try: signal_level=max(0,min(100,2*(int(parts[2])+100)))
except ValueError: continue
ssid=decode_ssid(parts[4])
if ssid: rows.append({'id':parts[0].lower(),'ssid':ssid,'signal':signal_level,'security':security(parts[3])})
return sorted(rows,key=lambda row:-row['signal'])[:128]
def android(*args, check=True):
result=subprocess.run(['/system/bin/'+args[0],*args[1:]],capture_output=True,text=True,timeout=5,
env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
if check and result.returncode: raise RuntimeError('WIFI_UNAVAILABLE')
return result.stdout.strip()
def command(value, required=True):
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as sock:
# An abstract return address is reachable by the host supplicant outside our chroot.
sock.bind('\0vulpes-wifi-'+uuid.uuid4().hex)
sock.settimeout(3);sock.connect(WPA)
sock.send(('IFNAME=wlan0 '+value).encode())
result=sock.recv(65536).decode('utf-8',errors='replace').strip()
if required and (result.startswith('FAIL') or result=='UNKNOWN COMMAND'):
raise RuntimeError('WIFI_UNAVAILABLE')
return result
class Wifi:
def __init__(self):
self.last_scan=[];self.original_enabled=None;self.route=None;self.last_lease=0
self.initial_resolver=Path('/etc/resolv.conf').read_bytes() if Path('/etc/resolv.conf').exists() else None
self.rule_owned=False;self.stopping=False
self.lease_network=None
def enabled(self):
try: return bool(int(Path('/sys/class/net/wlan0/flags').read_text(),16)&1)
except OSError: return False
def state(self):
enabled=self.enabled()
result={'available':True,'enabled':enabled,'state':'disconnected','network':None,'connectionInformation':{}}
if not enabled: result['state']='disabled';return result
status=dict(line.split('=',1) for line in command('STATUS').splitlines() if '=' in line)
result['macAddress']=status.get('address','')
if status.get('wpa_state')=='COMPLETED':
result['state']='connected'
result['network']={'ssid':decode_ssid(status.get('ssid','')),'savedId':status.get('id'),
'security':security(status.get('key_mgmt','')),'signal':50}
try: result['connectionInformation']['ipAddress']=str(ipaddress.IPv4Address(status.get('ip_address','')))
except ValueError: pass
poll=command('SIGNAL_POLL',False)
for line in poll.splitlines():
try:
name,value=line.split('=',1);value=int(value)
if name=='RSSI': result['network']['signal']=max(0,min(100,2*(value+100)))
if name=='LINKSPEED': result['connectionInformation']['linkSpeed']=value
except ValueError: pass
return result
def known(self):
rows=[]
for line in command('LIST_NETWORKS').splitlines()[1:]:
parts=line.split('\t',3)
if len(parts)!=4 or not parts[0].isdigit(): continue
flags=command('GET_NETWORK '+parts[0]+' key_mgmt',False)
rows.append({'id':parts[0],'ssid':decode_ssid(parts[1]),'security':security(flags)})
return rows
def scan(self):
state=self.state()
if not state['enabled']:return {**state,'networks':[]}
# Subscribe before requesting a scan so fast completions are not lost.
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as events:
events.bind('\0vulpes-wifi-scan-'+uuid.uuid4().hex);events.settimeout(3);events.connect(WPA)
events.send(b'IFNAME=wlan0 ATTACH');events.recv(4096)
command('SCAN',False)
deadline=time.monotonic()+4
while time.monotonic()<deadline:
try:
if b'CTRL-EVENT-SCAN-RESULTS' in events.recv(8192):break
except socket.timeout:break
rows=parse_scan(command('SCAN_RESULTS'));known=self.known();state=self.state()
for row in rows:
saved=next((n for n in known if n['ssid']==row['ssid'] and n['security']==row['security']),None)
row.update(known=bool(saved),savedId=saved['id'] if saved else None,
connected=bool(state['network'] and state['network']['ssid']==row['ssid']))
self.last_scan=rows
return {**state,'networks':rows}
def set_enabled(self, enabled):
if type(enabled) is not bool:raise ValueError('WIFI_INVALID_REQUEST')
if self.original_enabled is None:self.original_enabled=self.enabled()
self.route=None;self.lease_network=None;self.last_lease=0
if enabled:
android('ip','link','set','wlan0','up')
if not Path(WPA).exists():
android('setprop','ctl.start','wpa_supplicant')
for _ in range(30):
if Path(WPA).exists():break
time.sleep(.1)
command('RECONNECT')
else:
command('DISCONNECT',False)
android('ip','link','set','wlan0','down')
return self.state()
def connect(self,value):
if not self.enabled():raise RuntimeError('WIFI_DISABLED')
if value['operation']=='connectHidden':
ssid=value.get('ssid');mode=value.get('security')
else:
target=next((n for n in self.last_scan if n['id']==value.get('id')),None)
if target is None:raise ValueError('WIFI_INVALID_REQUEST')
ssid,mode=target['ssid'],target['security']
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32:raise ValueError('WIFI_INVALID_REQUEST')
if mode not in ('open','wpa-psk'):raise ValueError('WIFI_UNSUPPORTED_SECURITY')
password=value.get('password','')
if not isinstance(password,str):raise ValueError('WIFI_INVALID_PASSWORD')
saved=next((n for n in self.known() if n['ssid']==ssid and n['security']==mode),None)
created=False
if saved and not password: ident=saved['id']
else:
if mode=='wpa-psk':
if re.fullmatch('[a-fA-F0-9]{64}',password):psk=password.lower()
elif 8<=len(password.encode())<=63:psk=hashlib.pbkdf2_hmac('sha1',password.encode(),ssid.encode(),4096,32).hex()
else:raise ValueError('WIFI_INVALID_PASSWORD')
ident=command('ADD_NETWORK')
if not ident.isdigit():raise RuntimeError('WIFI_UNAVAILABLE')
created=True
try:
if created:
command('SET_NETWORK '+ident+' ssid '+ssid.encode().hex())
command('SET_NETWORK '+ident+' key_mgmt '+('WPA-PSK' if mode=='wpa-psk' else 'NONE'))
if mode=='wpa-psk':command('SET_NETWORK '+ident+' psk '+psk)
if value['operation']=='connectHidden':command('SET_NETWORK '+ident+' scan_ssid 1')
command('SELECT_NETWORK '+ident)
deadline=time.monotonic()+20
while time.monotonic()<deadline:
if self.stopping:raise RuntimeError('WIFI_UNAVAILABLE')
state=self.state()
if state['state']=='connected' and state['network']['ssid']==ssid:
self.lease_network=None;self.last_lease=0
self.lease(state)
while time.monotonic()<deadline and not self.stopping:
if android('getprop','dhcp.wlan0.result')=='ok':break
time.sleep(.25)
else:raise RuntimeError('WIFI_DHCP_FAILED')
self.last_lease=0;self.lease(state)
if created and command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return self.state()
time.sleep(.25)
raise RuntimeError('WIFI_CONNECTION_FAILED')
except Exception:
if created:command('REMOVE_NETWORK '+ident,False)
command('ENABLE_NETWORK all',False);command('RECONNECT',False)
raise
def lease(self,state):
if time.monotonic()-self.last_lease<3:return
self.last_lease=time.monotonic()
if state['state']!='connected':
self.lease_network=None;self.route=None
return
network_id=(state['network']['ssid'],state['network'].get('savedId'))
if network_id!=self.lease_network:
self.lease_network=network_id;self.route=None
# init.qcom.rc supplies -ABKLG but no interface. ctl.restart drops
# the dynamic argument; ctl.start must explicitly append wlan0.
android('setprop','ctl.stop','dhcpcd_wlan0')
for _ in range(20):
if android('getprop','init.svc.dhcpcd_wlan0')=='stopped':break
time.sleep(.1)
android('setprop','dhcp.wlan0.result','')
android('setprop','ctl.start','dhcpcd_wlan0:wlan0')
return
if android('getprop','dhcp.wlan0.result')!='ok':return
servers=[]
for name in ('dns1','dns2'):
try: servers.append(str(ipaddress.ip_address(android('getprop','dhcp.wlan0.'+name))))
except ValueError: pass
if servers:
content=''.join('nameserver '+ip+'\n' for ip in dict.fromkeys(servers))+'options timeout:2 attempts:2\n'
path=Path('/etc/resolv.conf')
if not path.exists() or path.read_text()!=content:
temporary=path.with_suffix('.tmp');temporary.write_text(content);temporary.chmod(0o644);temporary.replace(path)
# glibc does not use Android netd's per-socket routing marks. Install a
# dedicated table only for unmarked browser uid 2000; retain vendor tables.
try:
addr=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.ipaddress'))
gateway=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.gateway'))
mask=android('getprop','dhcp.wlan0.mask')
network=ipaddress.IPv4Network(str(addr)+'/'+mask,strict=False)
if gateway not in network:return
except ValueError:return
route=(str(network),str(gateway))
if route==self.route and android('ip','route','show','table','21052'):return
if not self.rule_owned:
if android('ip','route','show','table','21052') or '21900:' in android('ip','rule'):return
android('ip','rule','add','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052')
self.rule_owned=True
android('ip','route','flush','table','21052')
android('ip','route','add',route[0],'dev','wlan0','table','21052')
android('ip','route','add','default','via',route[1],'dev','wlan0','table','21052')
self.route=route
def publish(self):
try:
state=self.state();self.lease(state)
except (OSError,RuntimeError,subprocess.SubprocessError):
state={'available':False,'enabled':False,'state':'unavailable','network':None}
STATE.parent.mkdir(parents=True,exist_ok=True)
temporary=STATE.with_suffix('.tmp');temporary.write_text(json.dumps(state));temporary.chmod(0o644);temporary.replace(STATE)
def request(self,value):
if not isinstance(value,dict):raise ValueError('WIFI_INVALID_REQUEST')
op=value.get('operation')
if op=='status':return self.state()
if op=='scan':return self.scan()
if op=='known':return {'networks':self.known()}
if op=='setEnabled':return self.set_enabled(value.get('enabled'))
if op in ('connect','connectHidden'):return self.connect(value)
if op=='disconnect':command('DISCONNECT');return self.state()
if op=='forget':
ident=value.get('id')
if not isinstance(ident,str) or not ident.isdigit() or len(ident)>6:raise ValueError('WIFI_INVALID_REQUEST')
command('REMOVE_NETWORK '+ident)
if command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return True
raise ValueError('NOT_SUPPORTED')
def restore(self):
if self.original_enabled is not None:
try:self.set_enabled(self.original_enabled)
except Exception:pass
if self.rule_owned:
android('ip','rule','del','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052',check=False)
android('ip','route','flush','table','21052',check=False)
resolver=Path('/etc/resolv.conf')
if self.initial_resolver is None:resolver.unlink(missing_ok=True)
else:resolver.write_bytes(self.initial_resolver)
def receive(conn, limit=8192):
data=b''
while b'\n' not in data:
part=conn.recv(4096)
if not part:break
data+=part
if len(data)>limit:raise ValueError('WIFI_INVALID_REQUEST')
return json.loads(data)
def main():
if sys.argv[1:]==['--client']:
raw=sys.stdin.buffer.read(8193)
if len(raw)>8192:raise ValueError('WIFI_INVALID_REQUEST')
value=json.loads(raw)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(40);conn.connect(SOCKET);conn.sendall(json.dumps(value).encode()+b'\n')
print(json.dumps(receive(conn,65536)))
return
if sys.argv[1:] or os.getuid()!=0:raise SystemExit('Root broker or --client required')
os.umask(0o022);wifi=Wifi();running=True
def stop(*_):
nonlocal running
running=False;wifi.stopping=True
signal.signal(signal.SIGTERM,stop)
Path(SOCKET).unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.settimeout(1)
print('Nokia Wi-Fi broker ready',flush=True)
try:
while running:
wifi.publish()
try:conn,_=server.accept()
except socket.timeout:continue
with conn:
conn.settimeout(2)
_,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,2000):continue
try:reply={'result':wifi.request(receive(conn))}
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
reply={'error':str(error) if str(error) in ERRORS else 'WIFI_UNAVAILABLE'}
try:conn.sendall(json.dumps(reply).encode()+b'\n')
except OSError:pass
finally:
wifi.restore();Path(SOCKET).unlink(missing_ok=True)
if __name__=='__main__':main()

View file

@ -0,0 +1,40 @@
Section "ServerFlags"
Option "AutoAddDevices" "false"
Option "AutoEnableDevices" "false"
Option "DontVTSwitch" "true"
Option "BlankTime" "0"
Option "StandbyTime" "0"
Option "SuspendTime" "0"
Option "OffTime" "0"
EndSection
Section "Module"
Disable "glx"
EndSection
Section "Device"
Identifier "NokiaFramebuffer"
Driver "fbdev"
Option "fbdev" "/dev/fb0"
Option "ShadowFB" "true"
EndSection
Section "Monitor"
Identifier "InternalDisplay"
EndSection
Section "Screen"
Identifier "InternalScreen"
Device "NokiaFramebuffer"
Monitor "InternalDisplay"
DefaultDepth 16
SubSectionSub "Display"
Depth 16
Virtual 240 320
EndSubSection
EndSection
Section "ServerLayout"
Identifier "Nokia"
Screen "InternalScreen"
EndSection