Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kiosk development environment for macOS #167

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ jobs:
- uses: DeterminateSystems/magic-nix-cache-action@v4
- run: cd kiosk && nix-shell --run bin/test

kiosk-platform-smoke-test:
# While the smoke test should run on other platforms, it does not currently
# run on GitHub's Ubuntu runners, as they don't support GUI applications.
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.8'
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
knuton marked this conversation as resolved.
Show resolved Hide resolved
- uses: DeterminateSystems/magic-nix-cache-action@v4
- name: Smoke Test
run: cd kiosk && nix-shell ./macos/shell.nix --run "python test/platform-smoke.py"

build-vm:
runs-on: ubuntu-latest
steps:
Expand Down
11 changes: 10 additions & 1 deletion kiosk/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ Cycle between two urls in a full screen, locked down browser based on [QtWebEngi

## Development

Run `nix-shell` to create a suitable development environment.
### Setup environment

- On Linux: `nix-shell`
- On macOS: `nix-shell ./macos/shell.nix`

Then, start the kiosk browser with, for example:

Expand All @@ -28,3 +31,9 @@ Then, point a Chromium-based browser to `http://127.0.0.1:3355`.

Additional documentation is available at:
https://doc.qt.io/qt-6/qtwebengine-debugging.html

## Supported platforms

The kiosk is written with use within PlayOS in mind (implying connman and DBus as part of the system). To allow for testing web pages in the kiosk on developer machines, macOS is also supported, with the following limitations:

- No proxy server support
5 changes: 0 additions & 5 deletions kiosk/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,5 @@ python3Packages.buildPythonApplication rec {
shellHook = ''
# Give access to kiosk_browser module
export PYTHONPATH=./:$PYTHONPATH

# Setup Qt environment
bashdir=$(mktemp -d)
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
exec "$bashdir/bash"
'';
}
27 changes: 13 additions & 14 deletions kiosk/kiosk_browser/browser_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import logging
import re
import time

from kiosk_browser import system
from kiosk_browser.system import System

# Config
reload_on_network_error_after = 5000 # ms
Expand All @@ -19,7 +18,7 @@ class Status(Enum):

class BrowserWidget(QtWidgets.QWidget):

def __init__(self, url, get_current_proxy, parent):
def __init__(self, url, get_current_proxy, parent, system: System):
QtWidgets.QWidget.__init__(self, parent)
self.setStyleSheet(f"background-color: white;")

Expand Down Expand Up @@ -47,8 +46,7 @@ def __init__(self, url, get_current_proxy, parent):
# Override user agent
self._webview.page().profile().setHttpUserAgent(user_agent_with_system(
user_agent = self._webview.page().profile().httpUserAgent(),
system_name = system.NAME,
system_version = system.VERSION
system = system,
))

# Allow sound playback without user gesture
Expand Down Expand Up @@ -139,20 +137,21 @@ def _view(self, status):
self._webview.show()
self._webview.setFocus()

def user_agent_with_system(user_agent, system_name, system_version):
def user_agent_with_system(user_agent: str, system: System):
"""Inject a specific system into a user agent string"""
pattern = re.compile('(Mozilla/5.0) \(([^\)]*)\)(.*)')
m = pattern.match(user_agent)

if m == None:
return f"{system_name}/{system_version} {user_agent}"
else:
if not m.group(2):
system_detail = f"{system_name} {system_version}"
else:
system_detail = f"{m.group(2)}; {system_name} {system_version}"
match m:
case None:
return f"{system.name}/{system.version} {user_agent}"
case m:
if not m.group(2):
system_detail = f"{system.name} {system.version}"
else:
system_detail = f"{m.group(2)}; {system.name} {system.version}"

return f"{m.group(1)} ({system_detail}){m.group(3)}"
return f"{m.group(1)} ({system_detail}){m.group(3)}"

def loading_page(parent):
""" Show a loader in the middle of a blank page.
Expand Down
160 changes: 160 additions & 0 deletions kiosk/kiosk_browser/dbus_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Monitor proxy changes and automatically apply changes in Qt application.
"""

import collections
import dbus
import logging
import threading
import urllib
from PyQt6.QtNetwork import QNetworkProxy
from dataclasses import dataclass
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
from kiosk_browser.proxy import Proxy, ProxyConf, Credentials

@dataclass
class Service:
state: str
proxy: ProxyConf | None

def parse_service(service: dbus.Struct) -> Service | None:
if len(service) >= 2 and 'State' in service[1]:
return Service(service[1]['State'], extract_manual_proxy(service[1]))
else:
return None

def extract_manual_proxy(service_conf: dbus.Dictionary) -> ProxyConf | None:
if 'Proxy' in service_conf:
proxy = service_conf['Proxy']
if 'Method' in proxy and 'Servers' in proxy:
method = proxy['Method']
servers = proxy['Servers']
if method == 'manual' and len(servers) >= 1:
return parse_proxy_url(servers[0])
else:
return None
else:
return None
else:
return None

def parse_proxy_url(url: str) -> ProxyConf | None:
if url.startswith('http://'):
parsed = urllib.parse.urlparse(url)
else:
parsed = urllib.parse.urlparse(f'http://{url}')

if parsed.hostname != None and parsed.port != None:
assert isinstance(parsed.hostname, str)
assert isinstance(parsed.port, int)

if parsed.username != None and parsed.password != None:
assert isinstance(parsed.username, str)
assert isinstance(parsed.password, str)

username = urllib.parse.unquote(parsed.username)
password = urllib.parse.unquote(parsed.password)
return ProxyConf(parsed.hostname, parsed.port, Credentials(username, password))
else:
return ProxyConf(parsed.hostname, parsed.port, None)
else:
logging.warning(f"Hostname or port missing in proxy url")
return None

def get_current_proxy(bus) -> ProxyConf | None:
"""Get current proxy from dbus.

Return the proxy of a connected service preferentially, or of a ready
service.

Return None if Connman is not installed (DBusException).
"""

try:
client = dbus.Interface(
bus.get_object('net.connman', '/'),
'net.connman.Manager')

# List services, each service is a (id, properties) struct
services = [parse_service(s) for s in client.GetServices()]

# The service with the default route will always be sorted at the top of
# the list. (From connman doc/overview-api.txt)
default_service = find(lambda s: s.state in ['online', 'ready'], services)

if default_service:
return default_service.proxy
else:
return None

except dbus.exceptions.DBusException:
return None

def find(f, xs):
return next((x for x in xs if f(x)), None)

def set_proxy_in_qt_app(hostname, port):
logging.info(f"Set proxy to {hostname}:{port} in Qt application")
network_proxy = QNetworkProxy()
network_proxy.setType(QNetworkProxy.HttpProxy)
network_proxy.setHostName(hostname)
network_proxy.setPort(port)
QNetworkProxy.setApplicationProxy(network_proxy)

def set_no_proxy_in_qt_app():
logging.info(f"Set no proxy in Qt application")
QNetworkProxy.setApplicationProxy(QNetworkProxy())



class DBusProxy(Proxy):
"""A Proxy class for DBus/Linux systems.

This class assumes that connman is the network manager and that it can be queried via DBus.
"""

_proxy: ProxyConf | None
_bus: dbus.SystemBus

def __init__(self):
super().__init__()

DBusGMainLoop(set_as_default=True)
self._bus = dbus.SystemBus()
self._proxy = get_current_proxy(self._bus)

def start_monitoring_daemon(self):
"""Use initial proxy in Qt application and watch for changes."""
self._use_in_qt_app()
thread = threading.Thread(target=self._monitor, args=[])
thread.daemon = True
thread.start()

def _monitor(self):
self._bus.add_signal_receiver(
handler_function = self._on_property_changed,
bus_name = 'net.connman',
member_keyword = 'PropertyChanged')

# Update just after monitoring is on, so that we do not miss any proxy
# modification that could have happen before.
self._update(get_current_proxy(self._bus))

loop = GLib.MainLoop()
loop.run()

def _on_property_changed(self, *args, **kwargs):
if len(args) >= 2 and args[0] == 'Proxy':
self._update(get_current_proxy(self._bus))

def _update(self, new_proxy):
"""Update the proxy and use in Qt application, if the value has changed."""
if new_proxy != self._proxy:
self._proxy = new_proxy
self._use_in_qt_app()

def _use_in_qt_app(self):
if self._proxy is not None:
set_proxy_in_qt_app(self._proxy.hostname, self._proxy.port)
else:
set_no_proxy_in_qt_app()
18 changes: 15 additions & 3 deletions kiosk/kiosk_browser/main_widget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from PyQt6 import QtWidgets, QtCore, QtGui

from kiosk_browser import browser_widget, captive_portal, dialogable_widget, proxy as proxy_module
from kiosk_browser import browser_widget, captive_portal, dialogable_widget
from kiosk_browser.system import System
import platform

class MainWidget(QtWidgets.QWidget):
""" Show website at kiosk_url.
Expand All @@ -13,8 +15,17 @@ class MainWidget(QtWidgets.QWidget):
def __init__(self, kiosk_url: str, settings_url: str, toggle_settings_key: str):
super(MainWidget, self).__init__()

if platform.system() in ['Darwin']:
from kiosk_browser.proxy import Proxy
import os
system = System(name = "PlayOS",
version = os.getenv("PLAYOS_VERSION","1.0.0-dev"))
else:
from kiosk_browser.dbus_proxy import DBusProxy as Proxy
system = System()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we moved this branching to the system module/System class?

It could offer something like a def infer() -> System function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


# Proxy
proxy = proxy_module.Proxy()
proxy = Proxy()
proxy.start_monitoring_daemon()

# Browser widget
Expand All @@ -25,7 +36,8 @@ def __init__(self, kiosk_url: str, settings_url: str, toggle_settings_key: str):
inner_widget = browser_widget.BrowserWidget(
url = kiosk_url,
get_current_proxy = proxy.get_current,
parent = self),
parent = self,
system = system),
on_close = self._close_dialog)

# Captive portal
Expand Down
Loading
Loading