├── test ├── test_helper.exs └── webengine_kiosk_test.exs ├── .formatter.exs ├── src ├── ui │ ├── link-clicked.wav │ └── window-clicked.wav ├── ElixirJsChannel.cpp ├── ui.qrc ├── Blanking.cpp ├── Blanking.h ├── ElixirJsChannel.h ├── KioskProgress.h ├── KioskSounds.h ├── StderrPipe.h ├── KioskView.h ├── ElixirComs.h ├── KioskProgress.cpp ├── kiosk.pro ├── KioskSettings.h ├── KioskView.cpp ├── KioskWindow.h ├── KioskSounds.cpp ├── KioskMessage.h ├── Kiosk.h ├── StderrPipe.cpp ├── KioskMessage.cpp ├── ElixirComs.cpp ├── KioskWindow.cpp ├── KioskSettings.cpp ├── main.cpp └── Kiosk.cpp ├── .circleci └── config.yml ├── .gitignore ├── Makefile ├── lib ├── webengine_kiosk │ ├── application.ex │ ├── options.ex │ ├── input_event.ex │ ├── message.ex │ └── kiosk.ex └── webengine_kiosk.ex ├── assets └── www │ ├── assets │ └── style.css │ └── index.html ├── LICENSE ├── config └── config.exs ├── mix.lock ├── CHANGELOG.md ├── mix.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /src/ui/link-clicked.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerves-web-kiosk/webengine_kiosk/HEAD/src/ui/link-clicked.wav -------------------------------------------------------------------------------- /src/ui/window-clicked.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerves-web-kiosk/webengine_kiosk/HEAD/src/ui/window-clicked.wav -------------------------------------------------------------------------------- /test/webengine_kiosk_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebengineKioskTest do 2 | use ExUnit.Case 3 | doctest WebengineKiosk 4 | end 5 | -------------------------------------------------------------------------------- /src/ElixirJsChannel.cpp: -------------------------------------------------------------------------------- 1 | #include "ElixirJsChannel.h" 2 | 3 | void ElixirJsChannel::send(const QString &messageStr) 4 | { 5 | emit received(messageStr); 6 | } 7 | -------------------------------------------------------------------------------- /src/ui.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | ui/link-clicked.wav 4 | ui/window-clicked.wav 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/Blanking.cpp: -------------------------------------------------------------------------------- 1 | #include "Blanking.h" 2 | #include 3 | 4 | Blanking::Blanking(QWidget *parent) : 5 | QLabel(parent) 6 | { 7 | setAlignment(Qt::AlignCenter); 8 | } 9 | 10 | void Blanking::mousePressEvent(QMouseEvent *event) 11 | { 12 | Q_UNUSED(event); 13 | emit mousePressed(); 14 | } 15 | -------------------------------------------------------------------------------- /src/Blanking.h: -------------------------------------------------------------------------------- 1 | #ifndef BLANKING_H 2 | #define BLANKING_H 3 | 4 | #include 5 | 6 | class Blanking : public QLabel 7 | { 8 | Q_OBJECT 9 | public: 10 | explicit Blanking(QWidget *parent = nullptr); 11 | 12 | signals: 13 | void mousePressed(); 14 | 15 | protected: 16 | void mousePressEvent(QMouseEvent *event); 17 | }; 18 | 19 | #endif // BLANKING_H 20 | -------------------------------------------------------------------------------- /src/ElixirJsChannel.h: -------------------------------------------------------------------------------- 1 | #ifndef ELIXIRJSCHANNEL_H 2 | #define ELIXIRJSCHANNEL_H 3 | 4 | #include 5 | 6 | class ElixirJsChannel : public QObject 7 | { 8 | public: 9 | Q_OBJECT 10 | 11 | signals: 12 | void received(const QString &messageStr); 13 | 14 | public slots: 15 | void send(const QString &messageStr); 16 | }; 17 | 18 | #endif // ELIXIRJSCHANNEL_H 19 | -------------------------------------------------------------------------------- /src/KioskProgress.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSKPROGRESS_H 2 | #define KIOSKPROGRESS_H 3 | 4 | #include 5 | #include 6 | 7 | class QProgressBar; 8 | 9 | class KioskProgress : public QWidget 10 | { 11 | Q_OBJECT 12 | public: 13 | explicit KioskProgress(QWidget *parent = nullptr); 14 | 15 | void setProgress(int p); 16 | 17 | private: 18 | QProgressBar *loadProgress_; 19 | }; 20 | 21 | #endif // KIOSKPROGRESS_H 22 | -------------------------------------------------------------------------------- /src/KioskSounds.h: -------------------------------------------------------------------------------- 1 | #ifndef QPLAYER_MULTIMEDIA_H 2 | #define QPLAYER_MULTIMEDIA_H 3 | 4 | #include 5 | #include 6 | 7 | class KioskSounds : public QObject 8 | { 9 | Q_OBJECT 10 | 11 | public: 12 | explicit KioskSounds(QObject *parent = nullptr); 13 | 14 | void play(const QUrl &soundFile); 15 | 16 | public slots: 17 | void player_error(QMediaPlayer::Error error); 18 | 19 | private: 20 | QMediaPlayer *player; 21 | }; 22 | 23 | #endif // QPLAYER_MULTIMEDIA_H 24 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/elixir:1.7.3 6 | working_directory: ~/repo 7 | environment: 8 | - MIX_ENV: test 9 | steps: 10 | - checkout 11 | - run: sudo apt -y update 12 | - run: sudo apt install qtwebengine5-dev qtmultimedia5-dev qt5-default -q 13 | - run: mix local.hex --force 14 | - run: mix deps.get 15 | - run: mix test 16 | - run: mix hex.build 17 | - run: mix docs 18 | - run: mix format --check-formatted 19 | -------------------------------------------------------------------------------- /src/StderrPipe.h: -------------------------------------------------------------------------------- 1 | #ifndef STDERRPIPE_H 2 | #define STDERRPIPE_H 3 | 4 | #include 5 | #include "KioskMessage.h" 6 | 7 | class QSocketNotifier; 8 | 9 | class StderrPipe : public QObject 10 | { 11 | Q_OBJECT 12 | public: 13 | explicit StderrPipe(QObject *parent = nullptr); 14 | 15 | signals: 16 | void inputReceived(const QByteArray &line); 17 | 18 | private slots: 19 | void process(); 20 | 21 | private: 22 | ssize_t tryDispatch(); 23 | 24 | private: 25 | QSocketNotifier *notifier_; 26 | int pipefd_[2]; 27 | char buffer_[1024]; 28 | size_t index_; 29 | }; 30 | 31 | 32 | #endif // STDERRPIPE_H 33 | -------------------------------------------------------------------------------- /src/KioskView.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSK_VIEW_H 2 | #define KIOSK_VIEW_H 3 | 4 | #include 5 | #include 6 | 7 | struct KioskSettings; 8 | 9 | class KioskView : public QWebEngineView 10 | { 11 | Q_OBJECT 12 | 13 | public: 14 | explicit KioskView(const KioskSettings *settings, QWidget *parent = nullptr); 15 | 16 | QWebEngineView *createWindow(QWebEnginePage::WebWindowType type); 17 | 18 | private slots: 19 | void handleWindowCloseRequested(); 20 | 21 | private: 22 | const KioskSettings *settings_; 23 | QWebEngineView *loader_; 24 | }; 25 | 26 | #endif // KIOSK_VIEW_H 27 | -------------------------------------------------------------------------------- /src/ElixirComs.h: -------------------------------------------------------------------------------- 1 | #ifndef ELIXIRCOMS_H 2 | #define ELIXIRCOMS_H 3 | 4 | #include 5 | #include "KioskMessage.h" 6 | 7 | class QSocketNotifier; 8 | 9 | class ElixirComs : public QObject 10 | { 11 | Q_OBJECT 12 | public: 13 | explicit ElixirComs(QObject *parent = nullptr); 14 | 15 | static void send(const KioskMessage &message); 16 | 17 | signals: 18 | void messageReceived(KioskMessage message); 19 | 20 | private slots: 21 | void process(); 22 | 23 | private: 24 | ssize_t tryDispatch(); 25 | 26 | private: 27 | QSocketNotifier *stdinNotifier_; 28 | char buffer_[16384]; 29 | size_t index_; 30 | }; 31 | 32 | #endif // ELIXIRCOMS_H 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | webengine_kiosk-*.tar 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX = $(MIX_APP_PATH)/priv 2 | BUILD = $(MIX_APP_PATH)/obj 3 | SRC_DIR = $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) 4 | 5 | # Override if using a specific Qt toolkit version 6 | QMAKE ?= qmake 7 | 8 | calling_from_make: 9 | mix compile 10 | 11 | all: copy_assets submake 12 | 13 | copy_assets: $(PREFIX) 14 | cp -r assets/www $(PREFIX) 15 | 16 | $(PREFIX) $(BUILD): 17 | mkdir -p $@ 18 | 19 | $(BUILD)/Makefile: $(BUILD) src/kiosk.pro 20 | cd $(BUILD) && $(QMAKE) $(SRC_DIR)/src/kiosk.pro 21 | 22 | submake: $(BUILD)/Makefile 23 | +$(MAKE) -j3 -C $(BUILD) 24 | +INSTALL_ROOT="$(MIX_APP_PATH)" $(MAKE) -C $(BUILD) install 25 | 26 | ifeq ($(MIX_APP_PATH),) 27 | clean: 28 | mix clean 29 | else 30 | clean: 31 | $(RM) -r $(PREFIX) $(BUILD) 32 | endif 33 | 34 | .PHONY: all clean submake copy_assets 35 | 36 | -------------------------------------------------------------------------------- /lib/webengine_kiosk/application.ex: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | opts = Application.get_all_env(:webengine_kiosk) 11 | 12 | children = 13 | if Code.ensure_loaded?(SystemRegistry) do 14 | [ 15 | {WebengineKiosk.InputEvent, opts} 16 | ] 17 | else 18 | [] 19 | end 20 | 21 | # See https://hexdocs.pm/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: WebengineKiosk.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/KioskProgress.cpp: -------------------------------------------------------------------------------- 1 | #include "KioskProgress.h" 2 | 3 | #include 4 | #include 5 | 6 | KioskProgress::KioskProgress(QWidget *parent) : QWidget(parent) 7 | { 8 | setMinimumWidth(100); 9 | setMinimumHeight(16); 10 | setFixedHeight(16); 11 | 12 | loadProgress_ = new QProgressBar(this); 13 | loadProgress_->setContentsMargins(2, 2, 2, 2); 14 | loadProgress_->setMinimumWidth(100); 15 | loadProgress_->setMinimumHeight(16); 16 | loadProgress_->setFixedHeight(16); 17 | loadProgress_->setAutoFillBackground(true); 18 | QPalette palette = this->palette(); 19 | palette.setColor(QPalette::Window, QColor(255,255,255,63)); 20 | loadProgress_->setPalette(palette); 21 | } 22 | 23 | void KioskProgress::setProgress(int p) 24 | { 25 | loadProgress_->setValue(p); 26 | } 27 | -------------------------------------------------------------------------------- /assets/www/assets/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background-color: #827f7d; 3 | height: 100%; 4 | margin: 0; 5 | font-family: Arial, Helvetica, sans-serif; 6 | } 7 | 8 | span { 9 | padding-top: 20px 10 | } 11 | 12 | .content { 13 | padding: 20px; 14 | } 15 | 16 | .left { 17 | padding-left: 40px; 18 | } 19 | 20 | .center { 21 | margin-top: 10px; 22 | display: block; 23 | text-align: center; 24 | margin-left: auto; 25 | margin-right: auto; 26 | } 27 | 28 | .topic { 29 | font-size: 16px; 30 | font-weight: bold; 31 | } 32 | 33 | .header { 34 | border-bottom: 1px solid rgba(0,0,0,0.5); 35 | margin-bottom: 20px; 36 | } 37 | 38 | .terminal span { 39 | font-family: monospace; 40 | font-size: 14px; 41 | } 42 | 43 | .terminal { 44 | text-align: left; 45 | padding-left: 10px; 46 | padding: 20px; 47 | margin-bottom: 30px; 48 | width: 80%; 49 | background-color: rgba(0,0,0,0.3); 50 | border-radius: .4em; 51 | } 52 | -------------------------------------------------------------------------------- /src/kiosk.pro: -------------------------------------------------------------------------------- 1 | QT = core gui network widgets multimedia webenginewidgets 2 | 3 | CONFIG += console link_pkgconfig c++11 4 | CONFIG -= app_bundle 5 | 6 | TARGET = kiosk 7 | TEMPLATE = app 8 | 9 | SOURCES += main.cpp\ 10 | ElixirJsChannel.cpp \ 11 | KioskSettings.cpp \ 12 | ElixirComs.cpp \ 13 | KioskView.cpp \ 14 | KioskMessage.cpp \ 15 | Kiosk.cpp \ 16 | KioskWindow.cpp \ 17 | KioskProgress.cpp \ 18 | Blanking.cpp \ 19 | KioskSounds.cpp \ 20 | StderrPipe.cpp 21 | 22 | HEADERS += \ 23 | ElixirJsChannel.h \ 24 | KioskSettings.h \ 25 | ElixirComs.h \ 26 | KioskView.h \ 27 | KioskMessage.h \ 28 | Kiosk.h \ 29 | KioskWindow.h \ 30 | KioskProgress.h \ 31 | Blanking.h \ 32 | KioskSounds.h \ 33 | StderrPipe.h 34 | 35 | RESOURCES += \ 36 | ui.qrc 37 | 38 | # The following line requires $INSTALL_ROOT to be set to $MIX_APP_PATH when 39 | # calling "make install". See $MIX_APP_PATH/obj/Makefile. 40 | target.path = /priv 41 | 42 | INSTALLS += target 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All of the Elixir code in this project has the following copyright and license: 2 | 3 | Copyright 2018 Frank Hunleth 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | The C++ code currently has some remnants of qt-webengine-kiosk which is covered 18 | by the LGPL-3.0. See 19 | https://github.com/mobileoverlord/qt-webengine-kiosk/blob/master/doc/lgpl.html. 20 | 21 | Additionally, please see the Qt WebEngine licensing at 22 | https://doc.qt.io/Qt-5/qtwebengine-licensing.html. While this project does not 23 | include the source code to Qt WebEngine, it is a necessary requirement for any 24 | system running it. 25 | 26 | -------------------------------------------------------------------------------- /src/KioskSettings.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSKSETTINGS_H 2 | #define KIOSKSETTINGS_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | class QCoreApplication; 9 | 10 | struct KioskSettings 11 | { 12 | explicit KioskSettings(const QCoreApplication &app); 13 | 14 | QString dataDir; 15 | bool clearCache; 16 | QUrl homepage; 17 | bool fullscreen; 18 | int width; 19 | int height; 20 | int monitor; 21 | QString opengl; 22 | bool proxyEnabled; 23 | bool proxySystem; 24 | QString proxyHostname; 25 | quint16 proxyPort; 26 | QString proxyUsername; 27 | QString proxyPassword; 28 | bool stayOnTop; 29 | bool progress; 30 | bool scaleWithDPI; 31 | double pageScale; 32 | 33 | bool soundsEnabled; 34 | QUrl windowClickedSound; 35 | QUrl linkClickedSound; 36 | 37 | bool hideCursor; 38 | bool javascriptEnabled; 39 | bool javascriptCanOpenWindows; 40 | bool debugKeysEnabled; 41 | 42 | uid_t uid; 43 | gid_t gid; 44 | 45 | qreal zoomFactor; 46 | 47 | QString blankImage; 48 | QColor backgroundColor; 49 | QString httpAcceptLanguage; 50 | QString httpUserAgent; 51 | }; 52 | 53 | #endif // KIOSKSETTINGS_H 54 | -------------------------------------------------------------------------------- /src/KioskView.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include "KioskView.h" 7 | 8 | #include "KioskWindow.h" 9 | #include "KioskSettings.h" 10 | 11 | KioskView::KioskView(const KioskSettings *settings, QWidget* parent): QWebEngineView(parent), 12 | settings_(settings), 13 | loader_(nullptr) 14 | { 15 | page()->setZoomFactor(settings_->zoomFactor); 16 | page()->setBackgroundColor(settings_->backgroundColor); 17 | if (!settings_->httpAcceptLanguage.isEmpty()) { 18 | page()->profile()->setHttpAcceptLanguage(settings_->httpAcceptLanguage); 19 | } 20 | if (!settings_->httpUserAgent.isEmpty()) { 21 | page()->profile()->setHttpUserAgent(settings_->httpUserAgent); 22 | } 23 | 24 | setFocusPolicy(Qt::StrongFocus); 25 | setContextMenuPolicy(Qt::PreventContextMenu); 26 | } 27 | 28 | void KioskView::handleWindowCloseRequested() 29 | { 30 | 31 | qDebug("KioskView::handleWindowCloseRequested"); 32 | } 33 | 34 | QWebEngineView *KioskView::createWindow(QWebEnginePage::WebWindowType /*type*/) 35 | { 36 | qDebug("KioskView::createWindow"); 37 | 38 | // Don't allow windows 39 | return nullptr; 40 | } 41 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :webengine_kiosk, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:webengine_kiosk, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /src/KioskWindow.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSK_WINDOW_H 2 | #define KIOSK_WINDOW_H 3 | 4 | #include 5 | #include 6 | 7 | #include "KioskSettings.h" 8 | 9 | class Kiosk; 10 | class KioskProgress; 11 | class KioskView; 12 | class Blanking; 13 | 14 | // NOTE: This is not a QMainWindow even though that would make a lot of sense. 15 | // For unknown reasons, QMainWindow and QWebEngineView interact in some 16 | // way that makes touch events not work on the Raspberry Pi. 17 | 18 | class KioskWindow : public QWidget 19 | { 20 | Q_OBJECT 21 | 22 | public: 23 | explicit KioskWindow(Kiosk *kiosk, const KioskSettings *settings); 24 | ~KioskWindow(); 25 | 26 | void setView(KioskView *view); 27 | void setBrowserVisible(bool enabled); 28 | 29 | void showProgress(int percent); 30 | void hideProgress(); 31 | 32 | signals: 33 | void wakeup(); 34 | 35 | public slots: 36 | void showBrowser(); 37 | void hideBrowser(); 38 | 39 | protected: 40 | void resizeEvent(QResizeEvent *event); 41 | 42 | private slots: 43 | void doRunJavascriptDialog(); 44 | void doGotoURLDialog(); 45 | 46 | private: 47 | Kiosk *kiosk_; 48 | const KioskSettings *settings_; 49 | 50 | KioskProgress *progress_; 51 | Blanking *blank_; 52 | KioskView *view_; 53 | 54 | bool showingBrowser_; 55 | }; 56 | 57 | #endif // KIOSK_WINDOW_H 58 | -------------------------------------------------------------------------------- /src/KioskSounds.cpp: -------------------------------------------------------------------------------- 1 | #include "KioskSounds.h" 2 | 3 | KioskSounds::KioskSounds(QObject *parent) : 4 | QObject(parent), 5 | player(nullptr) 6 | { 7 | } 8 | 9 | void KioskSounds::play(const QUrl &soundFile) 10 | { 11 | if (player == nullptr) { 12 | player = new QMediaPlayer(this); 13 | connect(player, SIGNAL(error(QMediaPlayer::Error)), SLOT(player_error(QMediaPlayer::Error))); 14 | } 15 | 16 | if (!soundFile.isEmpty()) { 17 | player->stop(); 18 | player->setMedia(soundFile); 19 | player->play(); 20 | } 21 | } 22 | 23 | void KioskSounds::player_error(QMediaPlayer::Error error) 24 | { 25 | switch (error) { 26 | case QMediaPlayer::NoError: 27 | default: 28 | qDebug("Got unexpected value for error: %d", error); 29 | break; 30 | 31 | case QMediaPlayer::ResourceError: 32 | qDebug("Player error: ResourceError"); 33 | break; 34 | 35 | case QMediaPlayer::FormatError: 36 | qDebug("Player error: FormatError"); 37 | break; 38 | 39 | case QMediaPlayer::NetworkError: 40 | qDebug("Player error: NetworkError"); 41 | break; 42 | 43 | case QMediaPlayer::AccessDeniedError: 44 | qDebug("Player error: AccessDeniedError"); 45 | break; 46 | 47 | case QMediaPlayer::ServiceMissingError: 48 | qDebug("Player error: ServiceMissingError"); 49 | break; 50 | 51 | case QMediaPlayer::MediaIsPlaylist: 52 | qDebug("Player error: MediaIsPlaylist"); 53 | break; 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.4.1", "07bb382826ee8d08d575a1981f971ed41bd5d7e86b917fd012a93c51b5d28727", [:mix], [], "hexpm"}, 4 | "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"}, 5 | "erlex": {:hex, :erlex, "0.2.5", "e51132f2f472e13d606d808f0574508eeea2030d487fc002b46ad97e738b0510", [:mix], [], "hexpm"}, 6 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 10 | "system_registry": {:hex, :system_registry, "0.8.2", "df791dc276652fcfb53be4dab823e05f8269b96ac57c26f86a67838dbc0eefe7", [:mix], [], "hexpm"}, 11 | } 12 | -------------------------------------------------------------------------------- /src/KioskMessage.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSKMESSAGE_H 2 | #define KIOSKMESSAGE_H 3 | 4 | #include 5 | 6 | class QUrl; 7 | 8 | class KioskMessage 9 | { 10 | public: 11 | enum Type { 12 | GoToURL = 1, 13 | RunJavascript = 2, 14 | LoadingPage = 3, 15 | Progress = 4, 16 | FinishedLoadingPage = 5, 17 | URLChanged = 6, 18 | Blank = 7, 19 | Wakeup = 8, 20 | Reload = 9, 21 | GoBack = 10, 22 | GoForward = 11, 23 | StopLoading = 12, 24 | SetZoom = 13, 25 | BrowserCrashed = 14, 26 | ConsoleLog = 15, 27 | ChannelMessage = 16 28 | }; 29 | 30 | explicit KioskMessage(const QByteArray &rawMessage); 31 | KioskMessage(Type type, QByteArray payload); 32 | KioskMessage(const char *buffer, int length); 33 | 34 | enum Type type() const; 35 | QByteArray payload() const; 36 | 37 | int length() const { return rawMessage_.length(); } 38 | const char *constData() const { return rawMessage_.constData(); } 39 | 40 | static KioskMessage progressMessage(int progress); 41 | static KioskMessage loadingPageMessage(); 42 | static KioskMessage finishedLoadingPageMessage(); 43 | static KioskMessage urlChanged(const QUrl &url); 44 | static KioskMessage wakeup(); 45 | static KioskMessage browserCrashed(int terminationStatus, int exitCode); 46 | static KioskMessage consoleLog(const QByteArray &line); 47 | static KioskMessage channelMessage(const QString &message); 48 | 49 | private: 50 | const QByteArray rawMessage_; 51 | }; 52 | 53 | #endif // KIOSKMESSAGE_H 54 | -------------------------------------------------------------------------------- /lib/webengine_kiosk/options.ex: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk.Options do 2 | @all_options [ 3 | :clear_cache, 4 | :data_dir, 5 | :homepage, 6 | :monitor, 7 | :opengl, 8 | :proxy_enable, 9 | :proxy_system, 10 | :proxy_host, 11 | :proxy_port, 12 | :proxy_username, 13 | :proxy_password, 14 | :stay_on_top, 15 | :progress, 16 | :sounds, 17 | :window_clicked_sound, 18 | :link_clicked_sound, 19 | :hide_cursor, 20 | :javascript, 21 | :javascript_can_open_windows, 22 | :debug_menu, 23 | :fullscreen, 24 | :width, 25 | :height, 26 | :uid, 27 | :gid, 28 | :blank_image, 29 | :background_color, 30 | :run_as_root, 31 | :http_accept_language, 32 | :http_user_agent 33 | ] 34 | 35 | @moduledoc false 36 | 37 | @doc """ 38 | Go through all of the arguments and check for bad ones. 39 | """ 40 | def check_args(args) do 41 | case Enum.find(args, &invalid_arg?/1) do 42 | nil -> :ok 43 | arg -> {:error, "Unknown option #{inspect(arg)}"} 44 | end 45 | end 46 | 47 | defp invalid_arg?({key, _value}) do 48 | !(key in @all_options) 49 | end 50 | 51 | @doc """ 52 | Add the default options to the user-supplied list. 53 | """ 54 | def add_defaults(args) do 55 | Keyword.merge(defaults(), args) 56 | end 57 | 58 | defp defaults() do 59 | # This is a runtime function so that the system can be queried for some options 60 | homepage_file = Application.app_dir(:webengine_kiosk, "priv/www/index.html") 61 | 62 | [ 63 | homepage: "file://" <> homepage_file, 64 | fullscreen: true, 65 | background_color: "black" 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /src/Kiosk.h: -------------------------------------------------------------------------------- 1 | #ifndef KIOSK_H 2 | #define KIOSK_H 3 | 4 | #include "KioskSettings.h" 5 | #include "ElixirJsChannel.h" 6 | #include 7 | 8 | class ElixirComs; 9 | class KioskMessage; 10 | class KioskWindow; 11 | class KioskView; 12 | class KioskProgress; 13 | class KioskSounds; 14 | class QWindow; 15 | class StderrPipe; 16 | 17 | class Kiosk : public QObject 18 | { 19 | Q_OBJECT 20 | public: 21 | explicit Kiosk(const KioskSettings *settings, QObject *parent = nullptr); 22 | 23 | void init(); 24 | 25 | public slots: 26 | void goToUrl(const QUrl &url); 27 | void runJavascript(const QString &program); 28 | void reload(); 29 | void goBack(); 30 | void goForward(); 31 | void stopLoading(); 32 | 33 | protected: 34 | bool eventFilter(QObject *object, QEvent *event); 35 | 36 | private slots: 37 | void handleRequest(const KioskMessage &message); 38 | 39 | void urlChanged(const QUrl &); 40 | void startLoading(); 41 | void setProgress(int p); 42 | void finishLoading(); 43 | void elixirMessageReceived(const QString &messageStr); 44 | 45 | void handleWakeup(); 46 | void handleRenderProcessTerminated(QWebEnginePage::RenderProcessTerminationStatus status, int exitCode); 47 | void handleStderr(const QByteArray &line); 48 | 49 | private: 50 | QRect calculateWindowRect() const; 51 | 52 | private: 53 | QWebChannel *webChannel_; 54 | ElixirJsChannel *elixirChannel_; 55 | 56 | const KioskSettings *settings_; 57 | ElixirComs *coms_; 58 | StderrPipe *stderrPipe_; 59 | 60 | KioskWindow *window_; 61 | KioskView *view_; 62 | 63 | bool loadingPage_; 64 | bool showPageWhenDone_; 65 | 66 | KioskSounds *player_; 67 | QWindow *theGoodWindow_; 68 | }; 69 | 70 | #endif // KIOSK_H 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.3.0 4 | 5 | * New features 6 | * Allow sending data from Javascript back to elixir using `QtWebChannel`. 7 | * Allow subscribing to events from `WebengineKiosk`. 8 | 9 | ## v0.2.5 10 | 11 | * Bug fixes 12 | * Move all build products under the `_build` directory. This fixes many build 13 | issues that started popping up after Nerves 1.4 and Elixir 1.8. Cleaning 14 | builds between changing `MIX_TARGET` is no longer needed. 15 | 16 | ## v0.2.4 17 | 18 | * Bug fixes 19 | * Remove minimum version check that didn't work on some qmake versions 20 | 21 | ## v0.2.3 22 | 23 | * Bug fixes 24 | * Check minimum Qt version to avoid supporting Chromium/Qt issues that have 25 | been fixed 26 | * Improve default home page to give more things to try. 27 | 28 | ## v0.2.2 29 | 30 | * Bug fixes 31 | * Capture output to stderr from Chromium and the kiosk port app and send it to 32 | the logger. Previously that output would get lost if you didn't have a 33 | serial console cable attached making it difficult to get diagnotic logs. 34 | * Add `:run_as_root` option get past uid/gid checks if this is something that 35 | you'd like to try. This is not recommended and has not identified an issue 36 | so far. 37 | 38 | ## v0.2.1 39 | 40 | * Bug fixes 41 | * Report web page crashes. When pages crash, Chromium handles them by printing 42 | to the console and doing nothing. Now there's a message that gets propogated 43 | up to Elixir and logged. The browser returns home. 44 | 45 | ## v0.2.0 46 | 47 | * Bug fixes 48 | * Fixed Raspberry Pi/Chromium/Qt bug that caused touch events to be lost when 49 | there's a network connection, but no Internet. 50 | * Improve handling of C++ code exits 51 | 52 | * New features 53 | * Support running under a non-root account. This is required now. 54 | * Improve default homepage 55 | * Add `set_zoom/2` to change the zoom level 56 | * Add helpers for setting permissions on the Raspberry Pi. Ideally these would 57 | move out of this project, but they're too convenient for now. 58 | 59 | ## v0.1.0 60 | 61 | Initial release 62 | 63 | -------------------------------------------------------------------------------- /src/StderrPipe.cpp: -------------------------------------------------------------------------------- 1 | #include "StderrPipe.h" 2 | #include 3 | 4 | #include 5 | #include 6 | 7 | StderrPipe::StderrPipe(QObject *parent) : 8 | QObject(parent), 9 | index_(0) 10 | { 11 | if (pipe(pipefd_) < 0) 12 | qFatal("pipe"); 13 | if (dup2(pipefd_[1], STDERR_FILENO) < 0) 14 | qFatal("dup2(stderr)"); 15 | 16 | notifier_ = new QSocketNotifier(pipefd_[0], QSocketNotifier::Read, this); 17 | connect(notifier_, SIGNAL(activated(int)), SLOT(process())); 18 | } 19 | 20 | /** 21 | * @brief Dispatch commands in the buffer 22 | * @return the number of bytes processed 23 | */ 24 | ssize_t StderrPipe::tryDispatch() 25 | { 26 | const char *newline = (const char *) memchr(buffer_, '\n', index_); 27 | if (!newline) { 28 | if (index_ == sizeof(buffer_)) { 29 | emit inputReceived(QByteArray(buffer_, sizeof(buffer_))); 30 | return sizeof(buffer_); 31 | } else 32 | return 0; 33 | } 34 | 35 | int newline_ix = (newline - buffer_); 36 | buffer_[newline_ix] = 0; 37 | 38 | emit inputReceived(QByteArray(buffer_, newline_ix)); 39 | return newline_ix + 1; 40 | } 41 | 42 | /** 43 | * @brief call to process any new requests from Erlang 44 | */ 45 | void StderrPipe::process() 46 | { 47 | ssize_t amountRead = read(pipefd_[0], buffer_, sizeof(buffer_) - index_); 48 | if (amountRead < 0) { 49 | /* EINTR is ok to get, since we were interrupted by a signal. */ 50 | if (errno == EINTR) 51 | return; 52 | 53 | /* Everything else is unexpected. */ 54 | qFatal("read failed"); 55 | } 56 | 57 | index_ += amountRead; 58 | for (;;) { 59 | ssize_t bytesProcessed = tryDispatch(); 60 | 61 | if (bytesProcessed == 0) { 62 | break; 63 | } else if (index_ > (size_t) bytesProcessed) { 64 | /* Processed the line and there's more. */ 65 | memmove(buffer_, &buffer_[bytesProcessed], index_ - bytesProcessed); 66 | index_ -= bytesProcessed; 67 | } else { 68 | /* Processed the whole buffer. */ 69 | index_ = 0; 70 | break; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/KioskMessage.cpp: -------------------------------------------------------------------------------- 1 | #include "KioskMessage.h" 2 | #include 3 | 4 | KioskMessage::KioskMessage(const QByteArray &rawMessage) : 5 | rawMessage_(rawMessage) 6 | { 7 | } 8 | 9 | KioskMessage::KioskMessage(KioskMessage::Type type, QByteArray payload) : 10 | rawMessage_(payload.prepend(static_cast(type))) 11 | { 12 | } 13 | 14 | KioskMessage::KioskMessage(const char *buffer, int length) : 15 | rawMessage_(buffer, length) 16 | { 17 | } 18 | 19 | KioskMessage::Type KioskMessage::type() const 20 | { 21 | return static_cast(rawMessage_.at(0)); 22 | } 23 | 24 | QByteArray KioskMessage::payload() const 25 | { 26 | return rawMessage_.mid(1); 27 | } 28 | 29 | KioskMessage KioskMessage::progressMessage(int progress) 30 | { 31 | char message[2] = {KioskMessage::Progress, static_cast(progress)}; 32 | return KioskMessage(message, sizeof(message)); 33 | } 34 | 35 | KioskMessage KioskMessage::loadingPageMessage() 36 | { 37 | char message[1] = {KioskMessage::LoadingPage}; 38 | return KioskMessage(message, sizeof(message)); 39 | } 40 | 41 | KioskMessage KioskMessage::finishedLoadingPageMessage() 42 | { 43 | char message[1] = {KioskMessage::FinishedLoadingPage}; 44 | return KioskMessage(message, sizeof(message)); 45 | } 46 | 47 | KioskMessage KioskMessage::urlChanged(const QUrl &url) 48 | { 49 | QByteArray str = url.toString().toUtf8(); 50 | return KioskMessage(KioskMessage::URLChanged, str); 51 | } 52 | 53 | KioskMessage KioskMessage::wakeup() 54 | { 55 | char message[1] = {KioskMessage::Wakeup}; 56 | return KioskMessage(message, sizeof(message)); 57 | } 58 | 59 | KioskMessage KioskMessage::browserCrashed(int terminationStatus, int exitCode) 60 | { 61 | char message[3] = {KioskMessage::BrowserCrashed, static_cast(terminationStatus), static_cast(exitCode)}; 62 | return KioskMessage(message, sizeof(message)); 63 | } 64 | 65 | KioskMessage KioskMessage::consoleLog(const QByteArray &line) 66 | { 67 | QByteArray message = line; 68 | message.prepend(KioskMessage::ConsoleLog); 69 | return KioskMessage(message, message.length()); 70 | } 71 | 72 | KioskMessage KioskMessage::channelMessage(const QString &message) 73 | { 74 | QByteArray message_ba = message.toUtf8(); 75 | return KioskMessage(KioskMessage::ChannelMessage, message_ba); 76 | } 77 | -------------------------------------------------------------------------------- /assets/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | webengine_kiosk 12 | 13 | 14 | 15 | 16 |
17 |
18 |

webengine_kiosk

19 |
20 |
Keyboard shortcuts
21 |
22 |
Ctrl-Q or ⌘Q = quit
23 |
F5 = reload
24 |
Ctrl-[ = go back
25 |
Ctrl-] = go forward
26 |
Ctrl-J = run Javascript (debug_keys: true)
27 |
Ctrl-O = go to URL (debug_keys: true)
28 |
29 |
Next steps in IEx
30 |
31 |
iex> WebengineKiosk.go_to_url(browser_pid, "https://elixir-lang.org")
32 |
:ok
33 |
iex> WebengineKiosk.go_home(browser_pid)
34 |
:ok
35 |
iex> WebengineKiosk.run_javascript(browser_pid, "window.alert('Hello, Elixir!')")
36 |
:ok
37 |
iex> WebengineKiosk.back(browser_pid)
38 |
:ok
39 |
iex> WebengineKiosk.blank(browser_pid)
40 |
:ok
41 |
iex> WebengineKiosk.unblank(browser_pid)
42 |
:ok
43 |
iex> WebengineKiosk.set_zoom(browser_pid, 2)
44 |
:ok
45 |
46 |
Useful links
47 |
48 | 51 | 54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /lib/webengine_kiosk/input_event.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(SystemRegistry) do 2 | defmodule WebengineKiosk.InputEvent do 3 | use GenServer 4 | 5 | require Logger 6 | 7 | @dev "/dev" 8 | @group 1000 9 | 10 | def start_link(opts) do 11 | GenServer.start_link(__MODULE__, opts) 12 | end 13 | 14 | def init(opts) do 15 | Application.ensure_all_started(:system_registry) 16 | gid = opts[:gid] || @group 17 | SystemRegistry.register() 18 | 19 | {:ok, 20 | %{ 21 | inputs: [], 22 | gid: gid(gid) 23 | }} 24 | end 25 | 26 | def handle_info( 27 | {:system_registry, :global, %{state: %{"subsystems" => %{"input" => inputs}}}}, 28 | %{inputs: inputs} = state 29 | ) do 30 | {:noreply, state} 31 | end 32 | 33 | def handle_info( 34 | {:system_registry, :global, %{state: %{"subsystems" => %{"input" => inputs}}} = reg}, 35 | state 36 | ) do 37 | inputs = Enum.reject(inputs, &(&1 in state.inputs)) 38 | 39 | {events, other_inputs} = 40 | Enum.split_with(inputs, &String.starts_with?(List.last(&1), "event")) 41 | 42 | new_events = 43 | events 44 | |> Enum.map(&{&1, get_in(reg, &1)}) 45 | |> Enum.reject(&is_nil(elem(&1, 1))) 46 | |> Enum.map(&{elem(&1, 0), Map.get(elem(&1, 1), "devname")}) 47 | |> Enum.filter(&(chgrp(elem(&1, 1), state.gid) == :ok)) 48 | |> Enum.map(&elem(&1, 0)) 49 | 50 | inputs = new_events ++ other_inputs ++ state.inputs 51 | 52 | {:noreply, %{state | inputs: inputs}} 53 | end 54 | 55 | def handle_info({:system_registry, _, _}, state) do 56 | {:noreply, state} 57 | end 58 | 59 | defp chgrp(devname, gid) do 60 | path = Path.join(@dev, devname) 61 | _ = Logger.info("webengine_kiosk: chgrp #{path}") 62 | File.chgrp(path, gid) 63 | end 64 | 65 | defp gid(gid) when is_integer(gid), do: gid 66 | 67 | defp gid(gid) when is_binary(gid) do 68 | case File.read("/etc/group") do 69 | {:ok, groups} -> 70 | group_rec = 71 | groups 72 | |> String.split() 73 | |> Enum.find(&String.starts_with?(&1, gid)) 74 | 75 | case group_rec do 76 | nil -> 77 | @group 78 | 79 | group_rec -> 80 | [_, _, gid | _] = String.split(group_rec, ":") 81 | String.to_integer(gid) 82 | end 83 | 84 | _ -> 85 | @group 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/webengine_kiosk/message.ex: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk.Message do 2 | # These should match KioskMessage.h 3 | @msg_go_to_url 1 4 | @msg_run_javascript 2 5 | @msg_loading_page 3 6 | @msg_progress 4 7 | @msg_finished_loading_page 5 8 | @msg_url_changed 6 9 | @msg_blank 7 10 | @msg_wakeup 8 11 | @msg_reload 9 12 | @msg_go_back 10 13 | @msg_go_forward 11 14 | @msg_stop_loading 12 15 | @msg_set_zoom 13 16 | @msg_browser_crashed 14 17 | @msg_console_log 15 18 | @msg_channel_msg 16 19 | 20 | @moduledoc false 21 | 22 | @spec go_to_url(String.t()) :: <<_::8, _::_*8>> 23 | def go_to_url(url), do: <<@msg_go_to_url, url::binary>> 24 | 25 | @spec run_javascript(String.t()) :: <<_::8, _::_*8>> 26 | def run_javascript(code), do: <<@msg_run_javascript, code::binary>> 27 | 28 | @spec blank(boolean()) :: <<_::16>> 29 | def blank(true), do: <<@msg_blank, 1>> 30 | def blank(false), do: <<@msg_blank, 0>> 31 | 32 | @spec reload() :: <<_::8>> 33 | def reload(), do: <<@msg_reload>> 34 | 35 | @spec go_back() :: <<_::8>> 36 | def go_back(), do: <<@msg_go_back>> 37 | 38 | @spec go_forward() :: <<_::8>> 39 | def go_forward(), do: <<@msg_go_forward>> 40 | 41 | @spec stop_loading() :: <<_::8>> 42 | def stop_loading(), do: <<@msg_stop_loading>> 43 | 44 | @spec set_zoom(number()) :: <<_::8, _::_*8>> 45 | def set_zoom(factor) when is_number(factor) do 46 | str = to_string(factor) 47 | <<@msg_set_zoom, str::binary>> 48 | end 49 | 50 | @spec decode(binary()) :: 51 | :finished_loading_page 52 | | :started_loading_page 53 | | :wakeup 54 | | {:progress, byte()} 55 | | {:unknown, byte()} 56 | | {:url_changed, String.t()} 57 | | {:browser_crashed, atom(), byte()} 58 | | {:console_log, String.t()} 59 | | {:channel_message, String.t()} 60 | def decode(<<@msg_progress, value>>), do: {:progress, value} 61 | def decode(<<@msg_url_changed, url::binary>>), do: {:url_changed, url} 62 | def decode(<<@msg_loading_page>>), do: :started_loading_page 63 | def decode(<<@msg_finished_loading_page>>), do: :finished_loading_page 64 | def decode(<<@msg_wakeup>>), do: :wakeup 65 | 66 | def decode(<<@msg_browser_crashed, status, exit_code>>) do 67 | {:browser_crashed, termination_status(status), exit_code} 68 | end 69 | 70 | def decode(<<@msg_console_log, message::binary>>) do 71 | {:console_log, message} 72 | end 73 | 74 | def decode(<<@msg_channel_msg, message::binary>>) do 75 | {:channel_message, message} 76 | end 77 | 78 | def decode(<>), do: {:unknown, msg_type} 79 | 80 | defp termination_status(0), do: :normal 81 | defp termination_status(1), do: :abnormal 82 | defp termination_status(2), do: :crashed 83 | defp termination_status(3), do: :killed 84 | defp termination_status(n), do: {:unknown, n} 85 | end 86 | -------------------------------------------------------------------------------- /src/ElixirComs.cpp: -------------------------------------------------------------------------------- 1 | #include "ElixirComs.h" 2 | #include 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | ElixirComs::ElixirComs(QObject *parent) : 10 | QObject(parent), 11 | index_(0) 12 | { 13 | stdinNotifier_ = new QSocketNotifier(STDIN_FILENO, QSocketNotifier::Read, this); 14 | connect(stdinNotifier_, SIGNAL(activated(int)), SLOT(process())); 15 | } 16 | 17 | /** 18 | * @brief Synchronously send a response back to Erlang 19 | * 20 | * @param response what to send back 21 | */ 22 | void ElixirComs::send(const KioskMessage &message) 23 | { 24 | ssize_t len = message.length(); 25 | uint16_t be_len = htons(len); 26 | 27 | iovec chunks[2]; 28 | chunks[0].iov_base = &be_len; 29 | chunks[0].iov_len = sizeof(uint16_t); 30 | chunks[1].iov_base = (void *) message.constData(); 31 | chunks[1].iov_len = len; 32 | 33 | len += sizeof(uint16_t); 34 | ssize_t wrote = 0; 35 | for (;;) { 36 | ssize_t amount_written = writev(STDOUT_FILENO, chunks, 2); 37 | if (amount_written < 0) { 38 | if (errno == EINTR) 39 | continue; 40 | 41 | qFatal("write"); 42 | } 43 | 44 | wrote += amount_written; 45 | if (wrote == len) 46 | break; 47 | 48 | for (int i = 0; i < 2; i++) { 49 | if (amount_written > (ssize_t) chunks[i].iov_len) { 50 | amount_written -= chunks[i].iov_len; 51 | chunks[i].iov_len = 0; 52 | } else { 53 | chunks[i].iov_len -= amount_written; 54 | break; 55 | } 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * @brief Dispatch commands in the buffer 62 | * @return the number of bytes processed 63 | */ 64 | ssize_t ElixirComs::tryDispatch() 65 | { 66 | /* Check for length field */ 67 | if (index_ < sizeof(uint16_t)) 68 | return 0; 69 | 70 | uint16_t be_len; 71 | memcpy(&be_len, buffer_, sizeof(uint16_t)); 72 | ssize_t msglen = ntohs(be_len); 73 | if (msglen + sizeof(uint16_t) > sizeof(buffer_)) 74 | qFatal("Message too long"); 75 | 76 | /* Check whether we've received the entire message */ 77 | if (msglen + sizeof(uint16_t) > index_) 78 | return 0; 79 | 80 | emit messageReceived(KioskMessage(&buffer_[2], msglen)); 81 | 82 | return msglen + sizeof(uint16_t); 83 | } 84 | 85 | /** 86 | * @brief call to process any new requests from Erlang 87 | */ 88 | void ElixirComs::process() 89 | { 90 | ssize_t amountRead = read(STDIN_FILENO, buffer_, sizeof(buffer_) - index_); 91 | if (amountRead < 0) { 92 | /* EINTR is ok to get, since we were interrupted by a signal. */ 93 | if (errno == EINTR) 94 | return; 95 | 96 | /* Everything else is unexpected. */ 97 | qFatal("read failed"); 98 | } else if (amountRead == 0) { 99 | /* EOF. Erlang process was terminated. This happens after a release or if there was an error. */ 100 | exit(EXIT_SUCCESS); 101 | } 102 | 103 | index_ += amountRead; 104 | for (;;) { 105 | ssize_t bytesProcessed = tryDispatch(); 106 | 107 | if (bytesProcessed == 0) { 108 | /* Only have part of the command to process. */ 109 | break; 110 | } else if (index_ > (size_t) bytesProcessed) { 111 | /* Processed the command and there's more data. */ 112 | memmove(buffer_, &buffer_[bytesProcessed], index_ - bytesProcessed); 113 | index_ -= bytesProcessed; 114 | } else { 115 | /* Processed the whole buffer. */ 116 | index_ = 0; 117 | break; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.0" 5 | @source_url "https://github.com/nerves-web-kiosk/webengine_kiosk" 6 | 7 | def project do 8 | [ 9 | app: :webengine_kiosk, 10 | version: @version, 11 | elixir: "~> 1.6", 12 | description: description(), 13 | package: package(), 14 | source_url: @source_url, 15 | compilers: [:elixir_make | Mix.compilers()], 16 | make_targets: ["all"], 17 | make_clean: ["clean"], 18 | make_error_message: make_help(), 19 | docs: docs(), 20 | dialyzer: [ 21 | flags: [:unmatched_returns, :error_handling, :race_conditions, :underspecs] 22 | ], 23 | deps: deps() 24 | ] 25 | end 26 | 27 | def application do 28 | [ 29 | extra_applications: [:logger], 30 | mod: {WebengineKiosk.Application, []} 31 | ] 32 | end 33 | 34 | defp description do 35 | "Display and control web pages on a local fullscreen browser" 36 | end 37 | 38 | defp package do 39 | [ 40 | files: [ 41 | "lib", 42 | "src", 43 | "assets", 44 | "test", 45 | "mix.exs", 46 | "README.md", 47 | "LICENSE", 48 | "CHANGELOG.md", 49 | "Makefile" 50 | ], 51 | licenses: ["Apache-2.0", "LGPL-3.0-only"], 52 | links: %{"GitHub" => @source_url} 53 | ] 54 | end 55 | 56 | defp deps do 57 | [ 58 | {:system_registry, "~> 0.8", optional: true}, 59 | {:elixir_make, "~> 0.6", runtime: false}, 60 | {:ex_doc, "~> 0.19", only: [:dev, :test], runtime: false}, 61 | {:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false} 62 | ] 63 | end 64 | 65 | defp docs do 66 | [ 67 | extras: ["README.md"], 68 | main: "readme", 69 | source_ref: "v#{@version}", 70 | source_url: @source_url 71 | ] 72 | end 73 | 74 | defp using_nerves? do 75 | System.get_env("CROSSCOMPILE") != nil 76 | end 77 | 78 | defp make_help do 79 | """ 80 | 81 | Please look above this message for the compiler error before filing an 82 | issue. The first error after the text `==> webengine_kiosk` is usually the 83 | most helpful. 84 | 85 | The webengine_kiosk library requires the Qt framework to build. 86 | 87 | """ <> make_help_os(:os.type(), using_nerves?()) 88 | end 89 | 90 | defp make_help_os({:unix, :darwin}, _nerves) do 91 | """ 92 | To install Qt using Homebrew, run `brew install qt`. Homebrew doesn't add 93 | qt to your path, so you'll need to add it or set the QMAKE environment 94 | variable. For example, `export QMAKE=/usr/local/opt/qt/bin/qmake`. 95 | """ 96 | end 97 | 98 | defp make_help_os({:unix, _}, true) do 99 | """ 100 | Please install Qt using your system's package manager or via source. Since 101 | this is a Nerves-based project, only `qmake` is needed. For Ubuntu, this 102 | looks like: 103 | 104 | sudo apt install qt5-qmake 105 | 106 | Another option is to set the `QMAKE` environment variable to the path to 107 | the qmake binary: 108 | 109 | QMAKE=~/Qt/5.11.1/gcc_64/bin/qmake 110 | """ 111 | end 112 | 113 | defp make_help_os({:unix, _}, false) do 114 | """ 115 | Please install Qt using your system's package manager or via source. Be 116 | sure to install the development headers and libraries for Qt Webengine. 117 | For Ubuntu, this looks like: 118 | 119 | sudo apt install qt5-default qtwebengine5-dev qtmultimedia5-dev 120 | """ 121 | end 122 | 123 | defp make_help_os({other, _}, _nerves) do 124 | """ 125 | I'm not familiar with installing Qt5 on #{inspect(other)}. Please install 126 | Qt5 using your platform's package manager or from source and consider 127 | making an issue or a PR to #{@source_url} to benefit other users. 128 | 129 | If you're getting an error that `qmake` isn't found, try setting the 130 | `QMAKE` environment variable to the path to `qmake`. 131 | """ 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /src/KioskWindow.cpp: -------------------------------------------------------------------------------- 1 | #include "KioskWindow.h" 2 | #include "Kiosk.h" 3 | #include "KioskProgress.h" 4 | #include "KioskView.h" 5 | #include "Blanking.h" 6 | 7 | #include 8 | #include 9 | #include 10 | 11 | KioskWindow::KioskWindow(Kiosk *kiosk, const KioskSettings *settings) : 12 | QWidget(), 13 | kiosk_(kiosk), 14 | settings_(settings), 15 | view_(nullptr), 16 | showingBrowser_(false) 17 | { 18 | setMinimumWidth(320); 19 | setMinimumHeight(200); 20 | 21 | blank_ = new Blanking(this); 22 | blank_->setStyleSheet(QString("background: %1").arg(settings->backgroundColor.name())); 23 | if (!settings->blankImage.isEmpty()) 24 | blank_->setPixmap(settings->blankImage); 25 | connect(blank_, SIGNAL(mousePressed()), SIGNAL(wakeup())); 26 | 27 | progress_ = new KioskProgress(this); 28 | progress_->hide(); 29 | 30 | QAction* action = new QAction(this); 31 | action->setShortcut(QKeySequence::Quit); 32 | action->setShortcutContext(Qt::ApplicationShortcut); 33 | connect(action, SIGNAL(triggered(bool)), qApp, SLOT(quit())); 34 | addAction(action); 35 | 36 | action = new QAction(this); 37 | action->setShortcut(QKeySequence(Qt::Key_F5)); 38 | action->setShortcutContext(Qt::WindowShortcut); 39 | connect(action, SIGNAL(triggered(bool)), kiosk, SLOT(reload())); 40 | addAction(action); 41 | 42 | action = new QAction(this); 43 | action->setShortcut(QKeySequence(Qt::CTRL + Qt::Key_BracketLeft)); 44 | action->setShortcutContext(Qt::WindowShortcut); 45 | connect(action, SIGNAL(triggered(bool)), kiosk, SLOT(goBack())); 46 | addAction(action); 47 | 48 | action = new QAction(this); 49 | action->setShortcut(QKeySequence(Qt::CTRL + Qt::Key_BracketRight)); 50 | action->setShortcutContext(Qt::WindowShortcut); 51 | connect(action, SIGNAL(triggered(bool)), kiosk, SLOT(goForward())); 52 | addAction(action); 53 | 54 | if (settings->debugKeysEnabled) { 55 | action = new QAction(this); 56 | action->setShortcut(QKeySequence(Qt::CTRL + Qt::Key_J)); 57 | action->setShortcutContext(Qt::WindowShortcut); 58 | connect(action, SIGNAL(triggered(bool)), SLOT(doRunJavascriptDialog())); 59 | addAction(action); 60 | 61 | action = new QAction(this); 62 | action->setShortcut(QKeySequence(Qt::CTRL + Qt::Key_O)); 63 | action->setShortcutContext(Qt::WindowShortcut); 64 | connect(action, SIGNAL(triggered(bool)), SLOT(doGotoURLDialog())); 65 | addAction(action); 66 | } 67 | } 68 | 69 | KioskWindow::~KioskWindow() 70 | { 71 | } 72 | 73 | void KioskWindow::setView(KioskView *view) 74 | { 75 | view_ = view; 76 | view_->setVisible(showingBrowser_); 77 | //view_->setParent(this); 78 | view_->stackUnder(progress_); 79 | QSize sz = size(); 80 | view_->setGeometry(0, 0, sz.width(), sz.height()); 81 | if (showingBrowser_) { 82 | view_->setEnabled(true); 83 | view_->setFocus(); 84 | } 85 | } 86 | 87 | void KioskWindow::setBrowserVisible(bool enabled) 88 | { 89 | showingBrowser_ = enabled; 90 | if (view_) { 91 | view_->setVisible(enabled); 92 | view_->setEnabled(enabled); 93 | blank_->setEnabled(!enabled); 94 | if (enabled) { 95 | view_->setFocus(); 96 | } 97 | } 98 | } 99 | 100 | void KioskWindow::showProgress(int percent) 101 | { 102 | progress_->setProgress(percent); 103 | progress_->show(); 104 | } 105 | 106 | void KioskWindow::hideProgress() 107 | { 108 | progress_->hide(); 109 | } 110 | 111 | void KioskWindow::showBrowser() 112 | { 113 | setBrowserVisible(true); 114 | } 115 | 116 | void KioskWindow::hideBrowser() 117 | { 118 | setBrowserVisible(false); 119 | } 120 | 121 | void KioskWindow::resizeEvent(QResizeEvent *event) 122 | { 123 | QSize sz = event->size(); 124 | blank_->setGeometry(0, 0, sz.width(), sz.height()); 125 | if (view_) 126 | view_->setGeometry(0, 0, sz.width(), sz.height()); 127 | 128 | int x = (sz.width() - progress_->width()) / 2; 129 | int y = (sz.height() - progress_->height()) / 2; 130 | progress_->setGeometry(x, y, progress_->width(), progress_->height()); 131 | } 132 | 133 | void KioskWindow::doRunJavascriptDialog() 134 | { 135 | bool ok; 136 | QString text = QInputDialog::getMultiLineText(this, tr("Kiosk"), 137 | tr("Enter some Javascript:"), "", &ok); 138 | if (ok && !text.isEmpty()) 139 | kiosk_->runJavascript(text); 140 | } 141 | 142 | void KioskWindow::doGotoURLDialog() 143 | { 144 | bool ok; 145 | QString uri = QInputDialog::getText(this, tr("Kiosk"), 146 | tr("Enter a URL:"), QLineEdit::Normal, "https://elixir-lang.org/", &ok); 147 | if (ok && !uri.isEmpty()) 148 | kiosk_->goToUrl(uri); 149 | } 150 | 151 | -------------------------------------------------------------------------------- /lib/webengine_kiosk/kiosk.ex: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk.Kiosk do 2 | use GenServer 3 | alias WebengineKiosk.{Message, Options} 4 | 5 | require Logger 6 | 7 | @moduledoc false 8 | 9 | @spec start_link(%{args: Keyword.t(), parent: pid}, GenServer.options()) :: 10 | {:ok, pid} | {:error, term} 11 | def start_link(%{args: args, parent: parent}, genserver_opts \\ []) do 12 | with :ok <- Options.check_args(args) do 13 | GenServer.start_link(__MODULE__, %{args: args, parent: parent}, genserver_opts) 14 | end 15 | end 16 | 17 | def init(%{args: args, parent: parent}) do 18 | priv_dir = :code.priv_dir(:webengine_kiosk) 19 | cmd = Path.join(priv_dir, "kiosk") 20 | 21 | if !File.exists?(cmd) do 22 | _ = Logger.error("Kiosk port application is missing. It should be at #{cmd}.") 23 | raise "Kiosk port missing" 24 | end 25 | 26 | all_options = Options.add_defaults(args) 27 | 28 | cmd_args = 29 | all_options 30 | |> Enum.flat_map(fn {key, value} -> ["--#{key}", to_string(value)] end) 31 | 32 | set_permissions(all_options) 33 | homepage = Keyword.get(all_options, :homepage) 34 | 35 | port = 36 | Port.open({:spawn_executable, cmd}, [ 37 | {:args, cmd_args}, 38 | {:cd, priv_dir}, 39 | {:packet, 2}, 40 | :use_stdio, 41 | :binary, 42 | :exit_status 43 | ]) 44 | 45 | {:ok, %{port: port, homepage: homepage, parent: parent}} 46 | end 47 | 48 | def handle_call(:go_home, _from, state) do 49 | send_port(state, Message.go_to_url(state.homepage)) 50 | {:reply, :ok, state} 51 | end 52 | 53 | def handle_call({:go_to_url, url}, _from, state) do 54 | send_port(state, Message.go_to_url(url)) 55 | {:reply, :ok, state} 56 | end 57 | 58 | def handle_call({:run_javascript, code}, _from, state) do 59 | send_port(state, Message.run_javascript(code)) 60 | {:reply, :ok, state} 61 | end 62 | 63 | def handle_call({:blank, yes}, _from, state) do 64 | send_port(state, Message.blank(yes)) 65 | {:reply, :ok, state} 66 | end 67 | 68 | def handle_call(:reload, _from, state) do 69 | send_port(state, Message.reload()) 70 | {:reply, :ok, state} 71 | end 72 | 73 | def handle_call(:back, _from, state) do 74 | send_port(state, Message.go_back()) 75 | {:reply, :ok, state} 76 | end 77 | 78 | def handle_call(:forward, _from, state) do 79 | send_port(state, Message.go_forward()) 80 | {:reply, :ok, state} 81 | end 82 | 83 | def handle_call(:stop_loading, _from, state) do 84 | send_port(state, Message.stop_loading()) 85 | {:reply, :ok, state} 86 | end 87 | 88 | def handle_call({:set_zoom, factor}, _from, state) do 89 | send_port(state, Message.set_zoom(factor)) 90 | {:reply, :ok, state} 91 | end 92 | 93 | def handle_info({_, {:data, raw_message}}, state) do 94 | raw_message 95 | |> Message.decode() 96 | |> handle_browser_message(state) 97 | end 98 | 99 | def handle_info({port, {:exit_status, 0}}, %{port: port} = state) do 100 | _ = Logger.info("webengine_kiosk: normal exit from port") 101 | {:stop, :normal, state} 102 | end 103 | 104 | def handle_info({port, {:exit_status, status}}, %{port: port} = state) do 105 | _ = Logger.error("webengine_kiosk: unexpected exit from port: #{status}") 106 | {:stop, :unexpected_exit, state} 107 | end 108 | 109 | defp handle_browser_message({:browser_crashed, reason, _exit_status}, state) do 110 | _ = 111 | Logger.error( 112 | "webengine_kiosk: browser crashed: #{inspect(reason)}. Going home and hoping..." 113 | ) 114 | 115 | send_event(state.parent, {:browser_crashed, reason}) 116 | 117 | # Try to recover by going back home 118 | send_port(state, Message.go_to_url(state.homepage)) 119 | {:noreply, state} 120 | end 121 | 122 | defp handle_browser_message({:console_log, log}, state) do 123 | _ = Logger.warn("webengine_kiosk(stderr): #{log}") 124 | send_event(state.parent, {:console_log, log}) 125 | {:noreply, state} 126 | end 127 | 128 | defp handle_browser_message(message, state) do 129 | _ = Logger.debug("webengine_kiosk: received #{inspect(message)}") 130 | send_event(state.parent, message) 131 | {:noreply, state} 132 | end 133 | 134 | defp send_event(parent, event) do 135 | WebengineKiosk.dispatch_event(parent, event) 136 | end 137 | 138 | defp send_port(state, message) do 139 | send(state.port, {self(), {:command, message}}) 140 | end 141 | 142 | defp set_permissions(opts) do 143 | # Check if we are on a raspberry pi 144 | if File.exists?("/dev/vchiq") do 145 | File.chgrp("/dev/vchiq", 28) 146 | File.chmod("/dev/vchiq", 0o660) 147 | end 148 | 149 | if data_dir = Keyword.get(opts, :data_dir) do 150 | File.mkdir_p(data_dir) 151 | 152 | if uid = Keyword.get(opts, :uid) do 153 | chown(data_dir, uid) 154 | end 155 | end 156 | end 157 | 158 | defp chown(file, uid) when is_binary(uid) do 159 | case System.cmd("id", ["-u", uid]) do 160 | {uid, 0} -> 161 | uid = 162 | String.trim(uid) 163 | |> String.to_integer() 164 | 165 | chown(file, uid) 166 | 167 | _ -> 168 | :error 169 | end 170 | end 171 | 172 | defp chown(file, uid) when is_integer(uid) do 173 | File.chown(file, uid) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /src/KioskSettings.cpp: -------------------------------------------------------------------------------- 1 | #include "KioskSettings.h" 2 | #include 3 | #include 4 | #include 5 | 6 | static bool toBool(const QString &v) 7 | { 8 | return !v.isNull() && v == QLatin1String("true"); 9 | } 10 | 11 | KioskSettings::KioskSettings(const QCoreApplication &app) 12 | { 13 | QCommandLineParser parser; 14 | parser.setApplicationDescription("Kiosk browser for Elixir"); 15 | parser.addHelpOption(); 16 | parser.addVersionOption(); 17 | 18 | QList options = QList({ 19 | {"data_dir", "Data directory (defaults to subdirectories of $HOME)", "path", ""}, 20 | {"clear_cache", "Clear cached request data.", "bool", "true"}, 21 | {"homepage", "Set starting url", "url", ""}, 22 | {"monitor", "Display window on the th monitor.", "n", "0"}, 23 | {"fullscreen", "Run kiosk fullscreen", "bool", "true"}, 24 | {"width", "When not in fullscreen mode, this is the window width", "pixels", "1024"}, 25 | {"height", "When not in fullscreen mode, this is the window height", "pixels", "768"}, 26 | {"opengl", "Specify OpenGL preference.", "auto|software|gles|gl", "auto"}, 27 | {"proxy_enable", "Enable a proxy.", "bool", "false" }, 28 | {"proxy_system", "Use the system proxy.", "bool", "false" }, 29 | {"proxy_host", "Specify the proxy hostname.", "hostname" }, 30 | {"proxy_port", "Specify a proxy port number.", "port", "3128"}, 31 | {"proxy_username", "The username for the proxy.", "username"}, 32 | {"proxy_password", "The password for the proxy.", "password"}, 33 | {"stay_on_top", "Use to make the window stay on top", "bool", "true"}, 34 | {"progress", "Show the load progress.", "bool", "true"}, 35 | {"sounds", "Use to enable UI sounds.", "bool", "true"}, 36 | {"window_clicked_sound", "Path to a sound to play when then window is clicked.", "url", "qrc:///ui/window-clicked.wav"}, 37 | {"link_clicked_sound", "Path to a sound to play when then window is clicked.", "url", "qrc:///ui/link-clicked.wav"}, 38 | {"hide_cursor", "Specify the hide the mouse cursor.", "bool", "false"}, 39 | {"javascript", "Enable Javascript.", "bool", "true"}, 40 | {"javascript_can_open_windows", "Allow Javascript to open windows.", "bool", "false"}, 41 | {"debug_keys", "Enable a debug key shortcuts", "bool", "false"}, 42 | {"uid", "Drop priviledge and run as this uid.", "uid/user", ""}, 43 | {"gid", "Drop priviledge and run as this gid.", "gid/group", ""}, 44 | {"zoom_factor", "The zoom factor for the page (0.25 to 5.0).", "factor", "1.0"}, 45 | {"blank_image", "An image to use when the screen should be blank", "path", ""}, 46 | {"background_color", "The background color of the browser and blank screen (unless there's a blank_image)", "#RRGGBB or name", "white"}, 47 | {"run_as_root", "Explicitly allow the kiosk to run as the root user", "bool", "false"}, 48 | {"http_accept_language", "Overrides the default Accept-Language", "language-locale", ""}, 49 | {"http_user_agent", "Overrides the default User-Agent string", "string", ""} 50 | }); 51 | parser.addOptions(options); 52 | parser.process(app); 53 | 54 | dataDir = parser.value("data_dir"); 55 | clearCache = toBool(parser.value("clear_cache")); 56 | QString homePageAsString = parser.value("homepage"); 57 | if (!homePageAsString.isEmpty()) { 58 | homepage = QUrl(homePageAsString); 59 | } else { 60 | // The relative path here works both from being called by 61 | // Elixir and in QtCreator. 62 | QFileInfo fi("../priv/www/index.html"); 63 | QString homepageAsUri = QString("file://%1").arg(fi.canonicalFilePath()); 64 | homepage = QUrl(homepageAsUri); 65 | } 66 | monitor = parser.value("monitor").toInt(); 67 | fullscreen = toBool(parser.value("fullscreen")); 68 | width = parser.value("width").toInt(); 69 | height = parser.value("height").toInt(); 70 | opengl = parser.value("opengl"); 71 | proxyEnabled = toBool(parser.value("proxy_enable")); 72 | proxySystem = toBool(parser.value("proxy_system")); 73 | proxyHostname = parser.value("proxy_host"); 74 | proxyPort = static_cast(parser.value("proxy_host").toUInt()); 75 | proxyUsername = parser.value("proxy_username"); 76 | proxyPassword = parser.value("proxy_password"); 77 | stayOnTop = toBool(parser.value("stay_on_top")); 78 | progress = toBool(parser.value("progress")); 79 | soundsEnabled = toBool(parser.value("sounds")); 80 | windowClickedSound = QUrl(parser.value("window_clicked_sound")); 81 | linkClickedSound = QUrl(parser.value("link_clicked_sound")); 82 | hideCursor = toBool(parser.value("hide_cursor")); 83 | javascriptEnabled = toBool(parser.value("javascript")); 84 | javascriptCanOpenWindows = toBool(parser.value("javascript_can_open_windows")); 85 | debugKeysEnabled = toBool(parser.value("debug_keys")); 86 | uid = 0; // Set in main.c 87 | gid = 0; // Set in main.c 88 | zoomFactor = parser.value("zoom_factor").toDouble(); 89 | if (zoomFactor < 0.25) 90 | zoomFactor = 0.25; 91 | else if (zoomFactor > 5.0) 92 | zoomFactor = 5.0; 93 | blankImage = parser.value("blank_image"); 94 | backgroundColor = QColor(parser.value("background_color")); 95 | httpAcceptLanguage = parser.value("http_accept_language"); 96 | httpUserAgent = parser.value("http_user_agent"); 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebengineKiosk 2 | 3 | [![CircleCI](https://circleci.com/gh/nerves-web-kiosk/webengine_kiosk.svg?style=svg)](https://circleci.com/gh/nerves-web-kiosk/webengine_kiosk) 4 | [![Hex version](https://img.shields.io/hexpm/v/webengine_kiosk.svg "Hex version")](https://hex.pm/packages/webengine_kiosk) 5 | 6 | Launch and control a fullscreen web browser from Elixir. This is intended for 7 | kiosks running [Nerves](https://nerves-project.org/) but can be used anywhere 8 | you need to show a user a local web-based UI. 9 | 10 | Here's an example run: 11 | 12 | ```elixir 13 | iex> {:ok, kiosk} = WebengineKiosk.start_link(fullscreen: false) 14 | iex> WebengineKiosk.go_to_url(kiosk, "https://elixir-lang.org/") 15 | iex> WebengineKiosk.run_javascript(kiosk, "window.alert('Hello, Elixir!')") 16 | iex> WebengineKiosk.stop(kiosk) 17 | ``` 18 | 19 | It can also be linked into your application's supervision tree: 20 | 21 | ```elixir 22 | # Example childspecs 23 | 24 | [ 25 | {WebengineKiosk, {[homepage: "https://somewhere.com", background: "black"], name: Display}} 26 | ] 27 | 28 | # Somewhere else in your code 29 | 30 | WebengineKiosk.run_javascript(Display, "window.alert('Hello, Elixir!')") 31 | ``` 32 | 33 | ## Kiosk options 34 | 35 | The kiosk starts fullscreen and goes to a default local web page. To change 36 | this, set one or more options: 37 | 38 | * `background_color: color` - specify a background color as #RRGGBB or by name 39 | * `blank_image: path` - specify a path to an image for when the screen is blanked 40 | * `data_dir: path` - specify a writable path for data files 41 | * `debug_keys: boolean` - enable key combinations useful for debugging 42 | * `fullscreen: boolean` - show fullscreen 43 | * `gid: gid` - run the browser with this group id 44 | * `homepage: url` - load this page first. For local files, specify `file:///path/to/index.html` 45 | * `http_accept_language: string` - overrides the default Accept-Language string 46 | * `http_user_agent: string` - overrides the default UserAgent string 47 | * `monitor: index` - select the monitor for the web browser (0, 1, etc.) 48 | * `opengl: "gl" | "gles" | "software" | "auto"` - specify the OpenGL backend. This is only a hint. 49 | * `progress: boolean` - show a progress bar when loading pages 50 | * `run_as_root: boolean` - set to true if you really want to run Chromium as root 51 | * `sounds: boolean` - play sounds on clicks 52 | * `uid: uid` - run the browser as this user 53 | 54 | See `lib/webengine_kiosk.ex` for some untested options. 55 | 56 | ## Events 57 | 58 | Process can subscribe to receive events from `WebengineKiosk`. Subscribe using `register` function like this from another process: 59 | 60 | ```elixir 61 | WebengineKiosk.register(Display, self()) 62 | ``` 63 | 64 | If the subscribing process is `GenServer` you can handle events like this: 65 | 66 | ```elixir 67 | def handle_info({:webengine_kiosk, {:channel_message, message}}, state) do 68 | # Do something with event ... 69 | {:noreply, state} 70 | end 71 | ``` 72 | 73 | ## Sending messages back to elixir 74 | 75 | You can send data from Javascript back to elixir using `QtWebChannel`. 76 | 77 | First you need to load appropriate javascript library: 78 | 79 | Place this somewhere in `` 80 | ```html 81 | 82 | ``` 83 | 84 | Than in your javascript code initialize `QtWebChannel`: 85 | 86 | ```javascript 87 | new QWebChannel(qt.webChannelTransport, function (channel) { 88 | var JSobject = channel.objects.elixirJsChannel; 89 | window.elixirJsChannel = JSobject; 90 | }); 91 | ``` 92 | 93 | than anywhere in your javascript code you can call following function to send messages: 94 | 95 | ```javascript 96 | window.elixirJsChannel.send("Hello world!"); 97 | ``` 98 | 99 | The message will appear as `:channel_message` event. Only text can be sent, but if you want so send objects, you can serialize the to JSON and send them as such. 100 | 101 | ## Installation 102 | 103 | `WebengineKiosk` requires [Qt](http://qt.io/) version 5.10 or later (it may 104 | work on earlier versions, but we haven't tested it). It is likely that your 105 | package manager already has a Qt package. 106 | 107 | On Debian or Ubuntu: 108 | 109 | ```sh 110 | sudo apt install qtwebengine5-dev qtmultimedia5-dev qt5-default 111 | ``` 112 | 113 | On OSX: 114 | 115 | ```sh 116 | brew install qt 117 | 118 | # Homebrew doesn't automatically add `qmake` to your path, so run this when 119 | # building or add it to your .bashrc, .zshrc, etc. 120 | export PATH="/usr/local/opt/qt/bin:$PATH" 121 | ``` 122 | 123 | If you are installing Qt manually, then the first time that you run `mix`, 124 | you'll need to point to the installation location. If you don't, you'll either 125 | get an error that `qmake` isn't found or you'll being using your system's 126 | version of Qt. Here's an example commandline: 127 | 128 | ```sh 129 | QMAKE=~/Qt/5.11.1/gcc_64/bin/qmake mix compile 130 | ``` 131 | 132 | Finally, if you're using Nerves, you'll need a Nerves system that includes Qt. 133 | Take a look at 134 | [kiosk_system_rpi3](https://github.com/LeToteTeam/kiosk_system_rpi3) for an 135 | example. 136 | 137 | Once you've done all that, go ahead and add `webengine_kiosk` to your `mix.exs` 138 | dependencies like normal: 139 | 140 | ```elixir 141 | def deps do 142 | [ 143 | {:webengine_kiosk, "~> 0.2"} 144 | ] 145 | end 146 | ``` 147 | 148 | ## Permissions 149 | 150 | `WebengineKiosk` will refuse to run as the root user. You may specify a name or 151 | number using the `:uid` and `:gid` parameters to `WebengineKiosk.start_link/2`. 152 | If unspecified and running as root, `WebengineKiosk` will try to drop to a 153 | `kiosk` user and `kiosk` group by default. If dropping privileges, then you also 154 | need to ensure that `QtWebEngine` has a writable data directory. Use the 155 | `:data_dir` option do do this. 156 | 157 | The next set of permissions to check are platform-specific. Qt and Chromium 158 | directly access videos drivers and input devices. Usually the device files 159 | associated with those have group permissions and adding the user to the 160 | appropriate groups makes everything work. For example, on the Raspberry Pi, 161 | you'll need the kiosk user to be part of the `video` and `input` groups. You may 162 | also need to update the permissions and group ownership on `/dev/vchiq` if 163 | running on Nerves since it doesn't yet do that by default. 164 | 165 | ## Debugging immediate exits 166 | 167 | If the `kiosk` binary exits immediately then something basic is wrong. 168 | Unfortunately, errors often get printed to the terminal and don't end up in 169 | Elixir logs. If this happens to you, try running the `kiosk` binary by itself: 170 | 171 | ```elixir 172 | iex> path = Application.app_dir(:webengine_kiosk, "priv/kiosk") 173 | "/srv/erlang/lib/webengine_kiosk-0.1.0/priv/kiosk" 174 | iex> System.cmd(path, []) 175 | ``` 176 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | #include "Kiosk.h" 14 | #include "KioskSettings.h" 15 | #include "ElixirComs.h" 16 | 17 | static void kiosk_err_common(const char *format, va_list ap) 18 | { 19 | char *str; 20 | int len = vasprintf(&str, format, ap); 21 | if (len <= 0) 22 | return; 23 | 24 | ElixirComs::send(KioskMessage::consoleLog(QByteArray(str, len))); 25 | 26 | free(str); 27 | } 28 | 29 | static void kiosk_warnx(const char *format, ...) 30 | { 31 | va_list ap; 32 | va_start(ap, format); 33 | 34 | kiosk_err_common(format, ap); 35 | 36 | va_end(ap); 37 | } 38 | 39 | static void kiosk_errx(int status, const char *format, ...) 40 | { 41 | va_list ap; 42 | va_start(ap, format); 43 | 44 | kiosk_err_common(format, ap); 45 | 46 | va_end(ap); 47 | exit(status); 48 | } 49 | 50 | static uid_t stringToUid(const char *s) 51 | { 52 | if (*s == '\0') 53 | return 0; 54 | 55 | char *endptr; 56 | uid_t uid = static_cast(strtoul(s, &endptr, 0)); 57 | if (*endptr != '\0') { 58 | struct passwd *passwd = getpwnam(s); 59 | if (!passwd) 60 | kiosk_errx(EXIT_FAILURE, "Unknown user '%s'", s); 61 | uid = passwd->pw_uid; 62 | } 63 | if (uid == 0) 64 | kiosk_errx(EXIT_FAILURE, "Setting the user to root or uid 0 is not allowed"); 65 | return uid; 66 | } 67 | 68 | static gid_t stringToGid(const char *s) 69 | { 70 | if (*s == '\0') 71 | return 0; 72 | 73 | char *endptr; 74 | gid_t gid = static_cast(strtoul(s, &endptr, 0)); 75 | if (*endptr != '\0') { 76 | struct group *group = getgrnam(s); 77 | if (!group) 78 | kiosk_errx(EXIT_FAILURE, "Unknown group '%s'", s); 79 | gid = group->gr_gid; 80 | } 81 | if (gid == 0) 82 | kiosk_errx(EXIT_FAILURE, "Setting the group to root or gid 0 is not allowed"); 83 | return gid; 84 | } 85 | 86 | static void setOpenGLMode(const char *mode) 87 | { 88 | if (strcmp(mode, "gl") == 0) { 89 | QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); 90 | kiosk_warnx("OpenGL: Qt::AA_UseDesktopOpenGL"); 91 | } else if (strcmp(mode, "gles") == 0) { 92 | QCoreApplication::setAttribute(Qt::AA_UseOpenGLES); 93 | kiosk_warnx("OpenGL: Qt::AA_UseOpenGLES"); 94 | } else if (strcmp(mode, "software") == 0) { 95 | QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL); 96 | kiosk_warnx("OpenGL: Qt::AA_UseSoftwareOpenGL"); 97 | } else { 98 | kiosk_warnx("OpenGL: Default"); 99 | } 100 | } 101 | 102 | static void checkPermissions() 103 | { 104 | // Check permissions on directories since the error messages from 105 | // Chromium and QtWebEngine are pretty hard to debug unless you 106 | // run strace. Maybe this will help someone. 107 | struct stat st; 108 | if (stat("/dev/shm", &st) < 0 || (st.st_mode & 01777) != 01777) 109 | kiosk_errx(EXIT_FAILURE, "Check that \"/dev/shm\" exists and has mode 1777 (got %o)", st.st_mode & 01777); 110 | 111 | QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); 112 | if (!QDir().mkpath(path)) 113 | kiosk_errx(EXIT_FAILURE, "Check permissions on directories leading up to '%s' or specify --data_dir", qPrintable(path)); 114 | } 115 | 116 | int main(int argc, char *argv[]) 117 | { 118 | // Some settings need to be handled before Qt does anything. 119 | // Scan for them here. If there are issues, then KioskSettings 120 | // will report them. 121 | gid_t desired_gid = 0; 122 | uid_t desired_uid = 0; 123 | const char *desired_user = nullptr; 124 | bool run_as_root = false; 125 | 126 | for (int i = 1; i < argc - 1; i++) { 127 | if (strcmp(argv[i], "--gid") == 0) { 128 | desired_gid = stringToGid(argv[i + 1]); 129 | i++; 130 | } else if (strcmp(argv[i], "--uid") == 0) { 131 | desired_user = argv[i + 1]; 132 | desired_uid = stringToUid(argv[i + 1]); 133 | i++; 134 | } else if (strcmp(argv[i], "--opengl") == 0) { 135 | setOpenGLMode(argv[i + 1]); 136 | i++; 137 | } else if (strcmp(argv[i], "--data_dir") == 0) { 138 | // Qt derives the data directories for QtWebEngine 139 | // based on $HOME, so change it if the user specifies 140 | // --data_dir 141 | setenv("HOME", argv[i + 1], 1); 142 | i++; 143 | } else if(strcmp(argv[i], "--run_as_root") == 0) { 144 | run_as_root = (strcmp(argv[i + 1], "true") == 0); 145 | i++; 146 | } 147 | } 148 | 149 | gid_t current_gid = getgid(); 150 | uid_t current_uid = getuid(); 151 | 152 | if (run_as_root) { 153 | if (current_gid != 0 || current_uid != 0) 154 | kiosk_errx(EXIT_FAILURE, "Change to the root user if you specify --run_as_root"); 155 | 156 | // If the user isn't setting the CHROMIUM_FLAGS, then add --no-sandbox for them. 157 | setenv("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox", 0); 158 | } else if (current_gid == 0 || current_uid == 0) { 159 | // Running with elevated privileges. This isn't a good idea, so 160 | // see if the user specified a gid and uid or try to specify 161 | // one for them. 162 | if (desired_gid == 0) { 163 | kiosk_warnx("Running a web browser with gid == 0 is not allowed. Looking for a kiosk group."); 164 | desired_gid = stringToGid("kiosk"); 165 | } 166 | if (desired_uid == 0) { 167 | kiosk_warnx("Running a web browser with uid == 0 is not allowed. Looking for a kiosk user."); 168 | desired_user = "kiosk"; 169 | desired_uid = stringToUid(desired_user); 170 | } 171 | 172 | if (desired_gid == 0 || desired_uid == 0) 173 | kiosk_errx(EXIT_FAILURE, "Refusing to run with uid == %d and gid == %d", desired_uid, desired_gid); 174 | } 175 | 176 | // Drop/change privilege if requested 177 | // See https://wiki.sei.cmu.edu/confluence/display/c/POS36-C.+Observe+correct+revocation+order+while+relinquishing+privileges 178 | if (desired_gid > 0) { 179 | if (desired_user && initgroups(desired_user, desired_gid) < 0) 180 | kiosk_errx(EXIT_FAILURE, "initgroups(%s, %d) failed", desired_user, desired_gid); 181 | 182 | if (setgid(desired_gid) < 0) 183 | kiosk_errx(EXIT_FAILURE, "setgid(%d) failed", desired_gid); 184 | } 185 | 186 | if (desired_uid > 0 && setuid(desired_uid) < 0) 187 | kiosk_errx(EXIT_FAILURE, "setuid(%d) failed", desired_uid); 188 | 189 | QApplication app(argc, argv); 190 | KioskSettings settings(app); 191 | 192 | // Copy in the uid/gid settings for posterity. 193 | settings.uid = desired_uid; 194 | settings.gid = desired_gid; 195 | 196 | if (desired_gid || desired_uid) 197 | checkPermissions(); 198 | 199 | Kiosk kiosk(&settings); 200 | kiosk.init(); 201 | 202 | return app.exec(); 203 | } 204 | -------------------------------------------------------------------------------- /lib/webengine_kiosk.ex: -------------------------------------------------------------------------------- 1 | defmodule WebengineKiosk do 2 | use Supervisor 3 | 4 | def child_spec({opts, genserver_opts}) do 5 | id = genserver_opts[:id] || __MODULE__ 6 | 7 | %{ 8 | id: id, 9 | start: {__MODULE__, :start_link, [opts, genserver_opts]} 10 | } 11 | end 12 | 13 | def child_spec(opts) do 14 | child_spec({opts, []}) 15 | end 16 | 17 | @moduledoc """ 18 | Control a fullscreen web browser using Elixir for use in a kiosk 19 | """ 20 | 21 | @doc """ 22 | Start the kiosk. 23 | 24 | The kiosk starts fullscreen and goes to a default local web page. To change 25 | this, set one or more options: 26 | 27 | * `background_color: color` - specify a background color as #RRGGBB or by name 28 | * `blank_image: path` - specify a path to an image for when the screen is blanked 29 | * `data_dir: path` - specify a writable path for data files 30 | * `debug_keys: boolean` - enable key combinations useful for debugging 31 | * `fullscreen: boolean` - show fullscreen 32 | * `gid: gid` - run the browser with this group id 33 | * `homepage: url` - load this page first. For local files, specify `file:///path/to/index.html` 34 | * `http_accept_language: string` - overrides the default Accept-Language string 35 | * `http_user_agent: string` - overrides the default UserAgent string 36 | * `monitor: index` - select the monitor for the web browser (0, 1, etc.) 37 | * `opengl: "gl" | "gles" | "software" | "auto"` - specify the OpenGL backend. This is only a hint. 38 | * `progress: boolean` - show a progress bar when loading pages 39 | * `run_as_root: boolean` - set to true if you really want to run Chromium as root 40 | * `sounds: boolean` - play sounds on clicks 41 | * `uid: uid` - run the browser as this user 42 | 43 | Untested: 44 | 45 | * `clear_cache: boolean` 46 | * `proxy_enable: boolean` - enable/disable proxy support 47 | * `proxy_system: ` - 48 | * `proxy_host: hostname` - the host to connect to for using a proxy 49 | * `proxy_port: port` - the port to connect to on the proxy 50 | * `proxy_username: username` - a username for the proxy 51 | * `proxy_password: password` - a password for the proxy 52 | * `stay_on_top: boolean` - 53 | * `window_clicked_sound: url` - a sound to play when the window is clicked 54 | * `link_clicked_sound: url` - a sound to play when a link is clicked 55 | * `hide_cursor: boolean` - show or hide the mouse pointer 56 | * `javascript: boolean` - enable or disable Javascript support 57 | * `javascript_can_open_windows: boolean` - allow Javascript to open windows 58 | * `width: pixels` - when not fullscreen, the window is this width 59 | * `height: pixels` - when not fullscreen, the window is this height 60 | """ 61 | @spec start_link(Keyword.t(), GenServer.options()) :: {:ok, pid} | {:error, term} 62 | def start_link(args, genserver_opts \\ []) do 63 | Supervisor.start_link(__MODULE__, args, genserver_opts) 64 | end 65 | 66 | @doc """ 67 | Stop the kiosk 68 | """ 69 | @spec stop(Supervisor.supervisor()) :: :ok 70 | def stop(server) do 71 | Supervisor.stop(server) 72 | end 73 | 74 | @impl true 75 | def init(init_arg) do 76 | registry_name = 77 | "webengine_kiosk_registry_#{System.unique_integer([:positive])}" |> String.to_atom() 78 | 79 | children = [ 80 | Supervisor.child_spec({Registry, keys: :duplicate, name: registry_name}, id: :registry), 81 | Supervisor.child_spec({WebengineKiosk.Kiosk, %{args: init_arg, parent: self()}}, id: :kiosk) 82 | ] 83 | 84 | Supervisor.init(children, strategy: :one_for_one) 85 | end 86 | 87 | @doc """ 88 | Blank the screen 89 | 90 | The web browser will be replaced by a screen with the `blank_image`. If 91 | someone clicks or taps on the screen then a wakeup message will be sent. 92 | While the screen is in the blank state, it can still accept requests to go to 93 | other URLs. 94 | """ 95 | @spec blank(Supervisor.supervisor()) :: :ok 96 | def blank(server), do: call_kiosk(server, {:blank, true}) 97 | 98 | @doc """ 99 | Unblank the screen 100 | 101 | Show the web browser again. 102 | """ 103 | @spec unblank(Supervisor.supervisor()) :: :ok 104 | def unblank(server), do: call_kiosk(server, {:blank, false}) 105 | 106 | @doc """ 107 | Request that the browser go to the homepage. 108 | """ 109 | @spec go_home(Supervisor.supervisor()) :: :ok | {:error, term} 110 | def go_home(server), do: call_kiosk(server, :go_home) 111 | 112 | @doc """ 113 | Request that the browser go to the specified URL. 114 | """ 115 | @spec go_to_url(Supervisor.supervisor(), String.t()) :: :ok | {:error, term} 116 | def go_to_url(server, url), do: call_kiosk(server, {:go_to_url, url}) 117 | 118 | @doc """ 119 | Run Javascript in the browser. 120 | """ 121 | @spec run_javascript(Supervisor.supervisor(), String.t()) :: :ok | {:error, term} 122 | def run_javascript(server, code), do: call_kiosk(server, {:run_javascript, code}) 123 | 124 | @doc """ 125 | Reload the current page. 126 | """ 127 | @spec reload(Supervisor.supervisor()) :: :ok | {:error, term()} 128 | def reload(server), do: call_kiosk(server, :reload) 129 | 130 | @doc """ 131 | Go to the previously visited page. 132 | """ 133 | @spec back(Supervisor.supervisor()) :: :ok | {:error, term()} 134 | def back(server), do: call_kiosk(server, :back) 135 | 136 | @doc """ 137 | Go forward in history. 138 | """ 139 | @spec forward(Supervisor.supervisor()) :: :ok | {:error, term()} 140 | def forward(server), do: call_kiosk(server, :forward) 141 | 142 | @doc """ 143 | Stop loading the current page. 144 | """ 145 | @spec stop_loading(Supervisor.supervisor()) :: :ok | {:error, term()} 146 | def stop_loading(server), do: call_kiosk(server, :stop_loading) 147 | 148 | @doc """ 149 | Set the zoom factor for displaying the page. 150 | """ 151 | @spec set_zoom(Supervisor.supervisor(), number()) :: :ok | {:error, term()} 152 | def set_zoom(server, factor) when is_number(factor) and factor > 0 do 153 | call_kiosk(server, {:set_zoom, factor}) 154 | end 155 | 156 | @doc """ 157 | Register calling process to receive events 158 | """ 159 | @spec register(Supervisor.supervisor()) :: {:ok, pid} | {:error, term()} 160 | def register(server) do 161 | Registry.register(registry_name(server), "events", []) 162 | end 163 | 164 | @doc """ 165 | Unregister calling process from receiving events 166 | """ 167 | @spec unregister(Supervisor.supervisor()) :: :ok 168 | def unregister(server) do 169 | Registry.unregister(registry_name(server), "events") 170 | end 171 | 172 | @spec dispatch_event(Supervisor.supervisor(), any) :: :ok 173 | def dispatch_event(server, event) do 174 | Registry.dispatch(registry_name(server), "events", fn entries -> 175 | for {pid, _} <- entries, do: send(pid, event) 176 | end) 177 | end 178 | 179 | @spec registry_name(Supervisor.supervisor()) :: atom() 180 | defp registry_name(server) do 181 | {:ok, pid} = find_child(server, :registry) 182 | {:registered_name, reg_name} = Process.info(pid, :registered_name) 183 | reg_name 184 | end 185 | 186 | @spec call_kiosk(Supervisor.supervisor(), term) :: term 187 | defp call_kiosk(server, msg) do 188 | {:ok, pid} = find_child(server, :kiosk) 189 | GenServer.call(pid, msg) 190 | end 191 | 192 | @spec find_child(Supervisor.supervisor(), atom()) :: {:ok, pid} | :error 193 | defp find_child(supervisor, id) do 194 | ret = 195 | Supervisor.which_children(supervisor) 196 | |> Enum.find(fn {ch_id, _, _, _} -> ch_id == id end) 197 | 198 | if ret != nil do 199 | {_, pid, _, _} = ret 200 | {:ok, pid} 201 | else 202 | :error 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /src/Kiosk.cpp: -------------------------------------------------------------------------------- 1 | #include "Kiosk.h" 2 | #include "KioskWindow.h" 3 | #include "KioskView.h" 4 | #include "KioskProgress.h" 5 | #include "ElixirComs.h" 6 | #include "KioskSounds.h" 7 | #include "StderrPipe.h" 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | Kiosk::Kiosk(const KioskSettings *settings, QObject *parent) : 17 | QObject(parent), 18 | settings_(settings), 19 | coms_(nullptr), 20 | view_(nullptr), 21 | loadingPage_(false), 22 | showPageWhenDone_(true), 23 | theGoodWindow_(nullptr) 24 | { 25 | // Set up the UI 26 | window_ = new KioskWindow(this, settings); 27 | connect(window_, SIGNAL(wakeup()), SLOT(handleWakeup())); 28 | 29 | window_->setGeometry(calculateWindowRect()); 30 | 31 | player_ = settings->soundsEnabled ? new KioskSounds(this) : nullptr; 32 | 33 | qApp->installEventFilter(this); 34 | } 35 | 36 | void Kiosk::init() 37 | { 38 | if (settings_->proxyEnabled) { 39 | if (settings_->proxySystem) { 40 | QNetworkProxyFactory::setUseSystemConfiguration(true); 41 | } else { 42 | QNetworkProxy proxy; 43 | proxy.setType(QNetworkProxy::HttpProxy); 44 | proxy.setHostName(settings_->proxyHostname); 45 | proxy.setPort(settings_->proxyPort); 46 | if (!settings_->proxyUsername.isEmpty()) { 47 | proxy.setUser(settings_->proxyUsername); 48 | proxy.setPassword(settings_->proxyPassword); 49 | } 50 | QNetworkProxy::setApplicationProxy(proxy); 51 | } 52 | } 53 | 54 | if (settings_->hideCursor) 55 | QApplication::setOverrideCursor(Qt::BlankCursor); 56 | 57 | // Set up communication with Elixir 58 | coms_ = new ElixirComs(this); 59 | connect(coms_, SIGNAL(messageReceived(KioskMessage)), SLOT(handleRequest(KioskMessage))); 60 | 61 | // Take over stderr 62 | stderrPipe_ = new StderrPipe(this); 63 | connect(stderrPipe_, SIGNAL(inputReceived(QByteArray)), SLOT(handleStderr(QByteArray))); 64 | 65 | // Start the browser up 66 | view_ = new KioskView(settings_, window_); 67 | view_->settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, settings_->javascriptEnabled); 68 | view_->settings()->setAttribute(QWebEngineSettings::JavascriptCanOpenWindows, settings_->javascriptCanOpenWindows); 69 | 70 | // Set elixir channel 71 | elixirChannel_ = new ElixirJsChannel(); 72 | webChannel_ = new QWebChannel(this); 73 | webChannel_->registerObject("elixirJsChannel", elixirChannel_); 74 | view_->page()->setWebChannel(webChannel_); 75 | 76 | connect(view_, SIGNAL(loadStarted()), SLOT(startLoading())); 77 | connect(view_, SIGNAL(urlChanged(const QUrl &)), SLOT(urlChanged(const QUrl &))); 78 | connect(view_, SIGNAL(loadProgress(int)), SLOT(setProgress(int))); 79 | connect(view_, SIGNAL(loadFinished(bool)), SLOT(finishLoading())); 80 | connect(view_, SIGNAL(renderProcessTerminated(QWebEnginePage::RenderProcessTerminationStatus,int)), SLOT(handleRenderProcessTerminated(QWebEnginePage::RenderProcessTerminationStatus,int))); 81 | 82 | connect(elixirChannel_, SIGNAL(received(const QString &)), SLOT(elixirMessageReceived(const QString &))); 83 | 84 | window_->setView(view_); 85 | view_->load(settings_->homepage); 86 | 87 | if (settings_->fullscreen) 88 | window_->showFullScreen(); 89 | else 90 | window_->show(); 91 | } 92 | 93 | void Kiosk::goToUrl(const QUrl &url) 94 | { 95 | view_->load(url); 96 | } 97 | 98 | void Kiosk::runJavascript(const QString &program) 99 | { 100 | view_->page()->runJavaScript(program); 101 | } 102 | 103 | void Kiosk::reload() 104 | { 105 | view_->reload(); 106 | } 107 | 108 | void Kiosk::goBack() 109 | { 110 | view_->back(); 111 | } 112 | 113 | void Kiosk::goForward() 114 | { 115 | view_->forward(); 116 | } 117 | 118 | void Kiosk::stopLoading() 119 | { 120 | view_->stop(); 121 | } 122 | 123 | void Kiosk::handleRequest(const KioskMessage &message) 124 | { 125 | switch (message.type()) { 126 | case KioskMessage::GoToURL: 127 | goToUrl(QUrl(QString::fromUtf8(message.payload()))); 128 | break; 129 | 130 | case KioskMessage::RunJavascript: 131 | runJavascript(QString::fromUtf8(message.payload())); 132 | break; 133 | 134 | case KioskMessage::Blank: 135 | window_->setBrowserVisible(message.payload().at(0) == 0); 136 | break; 137 | 138 | case KioskMessage::Reload: 139 | reload(); 140 | break; 141 | 142 | case KioskMessage::GoBack: 143 | goBack(); 144 | break; 145 | 146 | case KioskMessage::GoForward: 147 | goForward(); 148 | break; 149 | 150 | case KioskMessage::StopLoading: 151 | stopLoading(); 152 | break; 153 | 154 | case KioskMessage::SetZoom: 155 | { 156 | qreal zoom = message.payload().toDouble(); 157 | if (zoom <= 0.01) 158 | zoom = 0.01; 159 | else if (zoom > 10.0) 160 | zoom = 10.0; 161 | 162 | view_->page()->setZoomFactor(zoom); 163 | break; 164 | } 165 | 166 | default: 167 | qFatal("Unknown message from Elixir: %d", message.type()); 168 | } 169 | } 170 | 171 | static bool isInputEvent(QEvent *event) 172 | { 173 | switch (event->type()) { 174 | case QEvent::TabletPress: 175 | case QEvent::TabletRelease: 176 | case QEvent::TabletMove: 177 | case QEvent::MouseButtonPress: 178 | case QEvent::MouseButtonRelease: 179 | case QEvent::MouseButtonDblClick: 180 | case QEvent::MouseMove: 181 | case QEvent::TouchBegin: 182 | case QEvent::TouchUpdate: 183 | case QEvent::TouchEnd: 184 | case QEvent::TouchCancel: 185 | case QEvent::ContextMenu: 186 | case QEvent::KeyPress: 187 | case QEvent::KeyRelease: 188 | case QEvent::Wheel: 189 | return true; 190 | default: 191 | return false; 192 | } 193 | } 194 | 195 | bool Kiosk::eventFilter(QObject *object, QEvent *event) 196 | { 197 | Q_UNUSED(object); 198 | 199 | if (object->isWindowType() && isInputEvent(event)) { 200 | QQuickWindow *qwin = dynamic_cast(object); 201 | if (qwin) { 202 | // All events are supposed to go to the QWidgetWindow. 203 | // However, on the Raspberry Pi, the order of the 204 | // QWidgetWindow and QQuickWindow gets swapped. Oddly 205 | // enough, this can be reliably reproduced when loading 206 | // pages with networking, but no Internet. Raising 207 | // the QWidgetWindow doesn't change which one gets 208 | // events. Therefore, if the QQuickWindow does get 209 | // an event, forward it over to the QWidgetWindow. 210 | if (theGoodWindow_) 211 | qApp->sendEvent(theGoodWindow_, event); 212 | } 213 | } 214 | 215 | // See https://bugreports.qt.io/browse/QTBUG-43602 for mouse events 216 | // seemingly not working with QWebEngineView. 217 | switch (event->type()) { 218 | case QEvent::MouseButtonPress: 219 | if (player_) 220 | player_->play(settings_->windowClickedSound); 221 | break; 222 | 223 | default: 224 | break; 225 | } 226 | 227 | return false; 228 | } 229 | 230 | void Kiosk::startLoading() 231 | { 232 | if (settings_->progress) 233 | window_->showProgress(0); 234 | 235 | coms_->send(KioskMessage::loadingPageMessage()); 236 | loadingPage_ = true; 237 | } 238 | 239 | void Kiosk::setProgress(int p) 240 | { 241 | if (settings_->progress) 242 | window_->showProgress(p); 243 | 244 | coms_->send(KioskMessage::progressMessage(p)); 245 | 246 | if (loadingPage_ && p >= 100) 247 | finishLoading(); 248 | } 249 | 250 | void Kiosk::finishLoading() 251 | { 252 | if (settings_->progress) 253 | window_->hideProgress(); 254 | 255 | if (loadingPage_) { 256 | coms_->send(KioskMessage::finishedLoadingPageMessage()); 257 | loadingPage_ = false; 258 | 259 | if (showPageWhenDone_) { 260 | // Let the event loop settle before showing the browser 261 | QTimer::singleShot(100, window_, SLOT(showBrowser())); 262 | } 263 | } 264 | 265 | // Force focus just in case it was lost somehow. 266 | QApplication::setActiveWindow(window_); 267 | window_->focusWidget(); 268 | 269 | // Capture the QWidgetWindow reference for the Raspberry Pi 270 | // input event workaround. See event() function for details. 271 | if (!theGoodWindow_) { 272 | // QWidgetWindow is private so verify that it's not a QQuickWindow which 273 | // isn't private and is the only alternative (to my knowledge). 274 | QWindow *win = qApp->focusWindow(); 275 | if (dynamic_cast(win) == nullptr) 276 | theGoodWindow_ = win; 277 | } 278 | } 279 | 280 | void Kiosk::handleWakeup() 281 | { 282 | coms_->send(KioskMessage::wakeup()); 283 | } 284 | 285 | void Kiosk::handleRenderProcessTerminated(QWebEnginePage::RenderProcessTerminationStatus status, int exitCode) 286 | { 287 | coms_->send(KioskMessage::browserCrashed(status, exitCode)); 288 | } 289 | 290 | void Kiosk::handleStderr(const QByteArray &line) 291 | { 292 | coms_->send(KioskMessage::consoleLog(line)); 293 | } 294 | 295 | void Kiosk::urlChanged(const QUrl &url) 296 | { 297 | coms_->send(KioskMessage::urlChanged(url)); 298 | 299 | // This is the real link clicked 300 | if (player_) 301 | player_->play(settings_->linkClickedSound); 302 | } 303 | 304 | void Kiosk::elixirMessageReceived(const QString &messageStr) 305 | { 306 | coms_->send(KioskMessage::channelMessage(messageStr)); 307 | } 308 | 309 | 310 | QRect Kiosk::calculateWindowRect() const 311 | { 312 | QList screens = QApplication::screens(); 313 | int screenToUse = 0; 314 | if (settings_->monitor >= 0 && settings_->monitor < screens.length()) 315 | screenToUse = settings_->monitor; 316 | 317 | QRect screenRect = screens.at(screenToUse)->geometry(); 318 | 319 | if (settings_->fullscreen) { 320 | return screenRect; 321 | } else { 322 | int windowWidth = qMax(320, qMin(screenRect.width(), settings_->width)); 323 | int windowHeight = qMax(240, qMin(screenRect.height(), settings_->height)); 324 | int offsetX = (screenRect.width() - windowWidth) / 2; 325 | int offsetY = (screenRect.height() - windowHeight) / 2; 326 | return QRect(screenRect.x() + offsetX, screenRect.y() + offsetY, windowWidth, windowHeight); 327 | } 328 | } 329 | --------------------------------------------------------------------------------