Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
323
tundra/runtime/bluetooth-control.py
Normal file
323
tundra/runtime/bluetooth-control.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""BlueZ broker. The persistent bus owns discovery and the pairing agent."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
SOCKET = Path('/run/tundra-bluetooth/control.sock')
|
||||
LIMIT = 65536
|
||||
|
||||
|
||||
def validate(value):
|
||||
if not isinstance(value, dict) or value.get('version') != 1:
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
op = value.get('operation')
|
||||
if op not in ('status', 'power', 'discoverable', 'rename', 'scan', 'stopScan',
|
||||
'pair', 'unpair', 'connect', 'disconnect', 'reply', 'cancelPair'):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair') and not re.fullmatch(
|
||||
r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', str(value.get('address', ''))):
|
||||
raise ValueError('BLUETOOTH_INVALID_ADDRESS')
|
||||
if op in ('power', 'discoverable') and not isinstance(value.get('enabled'), bool):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op == 'rename' and (not isinstance(value.get('name'), str) or
|
||||
not 1 <= len(value['name'].encode()) <= 248 or
|
||||
any(ord(c) < 32 for c in value['name'])):
|
||||
raise ValueError('BLUETOOTH_INVALID_NAME')
|
||||
if op == 'reply' and (not isinstance(value.get('accept'), bool) or
|
||||
not isinstance(value.get('id'), str) or len(value['id']) > 40):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
return op
|
||||
|
||||
|
||||
def device_record(props):
|
||||
cod = int(props.get('Class', 0))
|
||||
return dict(address=str(props['Address']), name=str(props.get('Alias', props['Address'])),
|
||||
paired=bool(props.get('Paired')), connected=bool(props.get('Connected')),
|
||||
uuids=[str(u) for u in props.get('UUIDs', [])],
|
||||
cod=dict(majorDeviceClass=(cod >> 8) & 31, minorDeviceClass=(cod >> 2) & 63,
|
||||
majorServiceClass=(cod >> 13) & 2047))
|
||||
|
||||
|
||||
def serve():
|
||||
import dbus
|
||||
import dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SystemBus(private=True)
|
||||
pending = {}
|
||||
discovery = {'owned': False, 'expires': 0}
|
||||
|
||||
def interface(path, name):
|
||||
return dbus.Interface(bus.get_object('org.bluez', path), name)
|
||||
|
||||
def objects():
|
||||
return interface('/', 'org.freedesktop.DBus.ObjectManager').GetManagedObjects()
|
||||
|
||||
def adapter():
|
||||
for path, interfaces in objects().items():
|
||||
if 'org.bluez.Adapter1' in interfaces:
|
||||
return str(path), interfaces['org.bluez.Adapter1']
|
||||
raise ValueError('BLUETOOTH_UNAVAILABLE')
|
||||
|
||||
def snapshot():
|
||||
path, props = adapter()
|
||||
return dict(available=True, address=str(props['Address']), name=str(props.get('Alias', 'Vulpes OS')),
|
||||
enabled=bool(props['Powered']), discoverable=bool(props['Discoverable']),
|
||||
discovering=bool(props['Discovering']),
|
||||
devices=[device_record(i['org.bluez.Device1']) for p, i in objects().items()
|
||||
if 'org.bluez.Device1' in i and str(i['org.bluez.Device1']['Adapter']) == path],
|
||||
pairing=[p['info'] for p in pending.values()])
|
||||
|
||||
def remote(address):
|
||||
path, _ = adapter()
|
||||
for p, interfaces in objects().items():
|
||||
props = interfaces.get('org.bluez.Device1', {})
|
||||
if str(props.get('Address', '')).upper() == address.upper() and str(props.get('Adapter')) == path:
|
||||
return str(p), props
|
||||
raise ValueError('BLUETOOTH_DEVICE_GONE')
|
||||
|
||||
class Rejected(dbus.DBusException):
|
||||
_dbus_error_name = 'org.bluez.Error.Rejected'
|
||||
|
||||
def drop(identity, error=True):
|
||||
row = pending.pop(identity, None)
|
||||
if row and error and row['fail']:
|
||||
row['fail'](Rejected('Pairing rejected or expired'))
|
||||
return False
|
||||
|
||||
def prompt(path, method, ok=None, fail=None, passkey=''):
|
||||
# One original Gaia pairing panel at a time, never auto-accept a peer.
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
props = interface(path, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
identity = str(uuid.uuid4())
|
||||
pending[identity] = dict(ok=ok, fail=fail, path=str(path),
|
||||
info=dict(id=identity, method=method, address=str(props['Address']),
|
||||
deviceName=str(props.get('Alias', props['Address'])), passkey=str(passkey)))
|
||||
GLib.timeout_add_seconds(90, drop, identity)
|
||||
|
||||
def check_sender(sender):
|
||||
if sender != bus.get_name_owner('org.bluez'):
|
||||
raise Rejected('Only BlueZ may request pairing')
|
||||
|
||||
def clear_pending():
|
||||
for identity in list(pending):drop(identity)
|
||||
|
||||
class Agent(dbus.service.Object):
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestConfirmation(self, device, passkey, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'confirmation', ok, fail, f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='s', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPinCode(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpincode', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPasskey(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpasskey', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestAuthorization(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def AuthorizeService(self, device, service, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
props = interface(device, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
if props.get('Trusted') and props.get('Paired'):
|
||||
ok()
|
||||
else:
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ouq', out_signature='', sender_keyword='sender')
|
||||
def DisplayPasskey(self, device, passkey, entered, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender')
|
||||
def DisplayPinCode(self, device, pincode, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=pincode)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Cancel(self, sender=None):
|
||||
check_sender(sender)
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Release(self, sender=None):
|
||||
check_sender(sender)
|
||||
clear_pending()
|
||||
|
||||
agent = Agent(bus, '/org/vulpes/BluetoothAgent')
|
||||
manager = interface('/org/bluez', 'org.bluez.AgentManager1')
|
||||
manager.RegisterAgent('/org/vulpes/BluetoothAgent', 'KeyboardDisplay')
|
||||
manager.RequestDefaultAgent('/org/vulpes/BluetoothAgent')
|
||||
|
||||
def stop_scan():
|
||||
if discovery['owned']:
|
||||
try:
|
||||
path, _ = adapter()
|
||||
interface(path, 'org.bluez.Adapter1').StopDiscovery()
|
||||
finally:
|
||||
discovery['owned'] = False
|
||||
|
||||
def expire_scan():
|
||||
if discovery['owned'] and time.monotonic() >= discovery['expires']:
|
||||
try:
|
||||
stop_scan()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
GLib.timeout_add_seconds(5, expire_scan)
|
||||
|
||||
def dispatch(value, finish):
|
||||
try:
|
||||
op = validate(value)
|
||||
path, props = adapter()
|
||||
prop = interface(path, 'org.freedesktop.DBus.Properties')
|
||||
if op == 'power':
|
||||
if not value['enabled']:
|
||||
stop_scan()
|
||||
prop.Set('org.bluez.Adapter1', 'Powered', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
if not value['enabled']:
|
||||
clear_pending()
|
||||
elif op == 'discoverable':
|
||||
prop.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
prop.Set('org.bluez.Adapter1', 'Discoverable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
elif op == 'rename':
|
||||
prop.Set('org.bluez.Adapter1', 'Alias', dbus.String(value['name'], variant_level=1))
|
||||
elif op == 'scan':
|
||||
if not discovery['owned']:
|
||||
interface(path, 'org.bluez.Adapter1').StartDiscovery()
|
||||
discovery['owned'] = True
|
||||
discovery['expires'] = time.monotonic() + 60
|
||||
elif op == 'stopScan':
|
||||
stop_scan()
|
||||
elif op == 'reply':
|
||||
row = pending.get(value['id'])
|
||||
if row is None:
|
||||
raise ValueError('BLUETOOTH_PAIRING_EXPIRED')
|
||||
method = row['info']['method']
|
||||
code = value.get('code', '')
|
||||
if value['accept'] and method in ('enterpincode', 'enterpasskey'):
|
||||
valid = isinstance(code, str) and 1 <= len(code) <= 16 and code.isascii() and code.isprintable()
|
||||
if method == 'enterpasskey':
|
||||
valid = isinstance(code, str) and re.fullmatch(r'[0-9]{1,6}', code)
|
||||
if not valid:
|
||||
raise ValueError('BLUETOOTH_INVALID_PIN')
|
||||
pending.pop(value['id'])
|
||||
if not value['accept'] and row['fail']:
|
||||
row['fail'](Rejected('Rejected by user'))
|
||||
elif row['ok']:
|
||||
if method == 'enterpincode': row['ok'](dbus.String(code))
|
||||
elif method == 'enterpasskey': row['ok'](dbus.UInt32(int(code)))
|
||||
else: row['ok']()
|
||||
elif op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair'):
|
||||
device, dp = remote(value['address'])
|
||||
obj = interface(device, 'org.bluez.Device1')
|
||||
if op == 'unpair':
|
||||
interface(path, 'org.bluez.Adapter1').RemoveDevice(dbus.ObjectPath(device))
|
||||
elif op == 'cancelPair':
|
||||
obj.CancelPairing()
|
||||
elif op in ('connect', 'disconnect'):
|
||||
if not dp.get('Paired'):
|
||||
raise ValueError('BLUETOOTH_NOT_PAIRED')
|
||||
getattr(obj, 'Connect' if op == 'connect' else 'Disconnect')(
|
||||
reply_handler=lambda: finish(True), error_handler=lambda e: finish(error(e)), timeout=60)
|
||||
return False
|
||||
else:
|
||||
stop_scan()
|
||||
def paired():
|
||||
# Trust follows an explicit successful Pair initiated from Gaia.
|
||||
try:
|
||||
interface(device, 'org.freedesktop.DBus.Properties').Set(
|
||||
'org.bluez.Device1', 'Trusted', dbus.Boolean(True, variant_level=1))
|
||||
for identity, row in list(pending.items()):
|
||||
if row['path']==device:drop(identity,False)
|
||||
finish(True)
|
||||
except Exception as exc:finish(error(exc))
|
||||
obj.Pair(reply_handler=paired, error_handler=lambda e: finish(error(e)), timeout=95)
|
||||
return False
|
||||
finish(snapshot())
|
||||
except Exception as e:
|
||||
finish(error(e))
|
||||
return False
|
||||
|
||||
def error(exc):
|
||||
name = str(exc).split(':', 1)[0]
|
||||
if hasattr(exc, 'get_dbus_name'):
|
||||
name = exc.get_dbus_name().rsplit('.', 1)[-1]
|
||||
return {'error': name if re.fullmatch(r'[A-Za-z_]+', name) else 'BLUETOOTH_FAILED'}
|
||||
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
def connection(sock):
|
||||
try:
|
||||
with sock:
|
||||
sock.settimeout(110)
|
||||
data = b''
|
||||
while not data.endswith(b'\n') and len(data) <= 4096:
|
||||
chunk = sock.recv(4097-len(data))
|
||||
if not chunk: break
|
||||
data += chunk
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
value = json.loads(data)
|
||||
validate(value)
|
||||
done = threading.Event(); reply = []
|
||||
def finish(result):
|
||||
reply.append(result); done.set()
|
||||
GLib.idle_add(dispatch, value, finish)
|
||||
if not done.wait(100): raise ValueError('BLUETOOTH_TIMEOUT')
|
||||
result = reply[0]
|
||||
sock.sendall(json.dumps(result if isinstance(result, dict) and 'error' in result else {'result':result}).encode()+b'\n')
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
slots.release()
|
||||
|
||||
def listen():
|
||||
import pwd
|
||||
SOCKET.parent.mkdir(parents=True, exist_ok=True)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET)); os.chmod(SOCKET, 0o660)
|
||||
os.chown(SOCKET, 0, pwd.getpwnam('droidian').pw_gid)
|
||||
server.listen(8)
|
||||
while True:
|
||||
sock, _ = server.accept()
|
||||
if not slots.acquire(blocking=False): sock.close(); continue
|
||||
threading.Thread(target=connection, args=(sock,), daemon=True).start()
|
||||
threading.Thread(target=listen, daemon=True).start()
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def client():
|
||||
data = sys.stdin.read(4097)
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
validate(json.loads(data))
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(110); sock.connect(str(SOCKET)); sock.sendall(data.encode()+b'\n')
|
||||
result = b''
|
||||
while not result.endswith(b'\n') and len(result) <= LIMIT:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk: break
|
||||
result += chunk
|
||||
if len(result) > LIMIT: raise ValueError('BLUETOOTH_INVALID_REPLY')
|
||||
print(result.decode())
|
||||
|
||||
if __name__ == '__main__':
|
||||
client() if '--client' in sys.argv else serve()
|
||||
Loading…
Reference in a new issue