├── debian ├── compat ├── source │ └── format ├── rules ├── control └── copyright ├── docs ├── _static │ ├── .gitkeep │ ├── version.json │ └── images │ │ └── logo.png ├── _templates │ ├── .gitkeep │ └── layout.html ├── autobuild.sh ├── Makefile ├── continuous-integration.rst ├── app-templates.rst ├── builders.rst ├── install.rst ├── debugging.rst ├── index.rst ├── getting-started.rst ├── env-vars.rst ├── usage.rst ├── conf.py └── commands.rst ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_shell.py │ ├── test_publish.py │ ├── test_desktop.py │ ├── test_no_lock.py │ ├── test_writable_image.py │ ├── test_review.py │ ├── test_update.py │ ├── test_build_libs.py │ ├── test_create.py │ ├── test_devices.py │ ├── test_run.py │ ├── base_test.py │ ├── test_build.py │ ├── test_log.py │ ├── test_logs.py │ ├── test_config.py │ ├── test_launch.py │ ├── test_clean.py │ ├── test_install.py │ ├── test_architectures.py │ └── test_ide_qtcreator.py ├── integration │ ├── __init__.py │ ├── base_test.py │ └── test_templates.py └── mocks │ ├── __init__.py │ ├── clickable.py │ └── config.py ├── clickable ├── config │ ├── __init__.py │ ├── constants.py │ ├── clickable.schema │ └── file_helpers.py ├── builders │ ├── __init__.py │ ├── base.py │ ├── custom.py │ ├── cmake.py │ ├── qmake.py │ ├── go.py │ ├── make.py │ ├── pure.py │ ├── cordova.py │ └── rust.py ├── commands │ ├── __init__.py │ ├── idedelegates │ │ ├── __init__.py │ │ ├── qtcreator │ │ │ ├── QtProject.tar.xz │ │ │ └── Readme.md │ │ └── idedelegate.py │ ├── docker │ │ ├── docker_support.py │ │ ├── debug_valgrind_support.py │ │ ├── webapp_support.py │ │ ├── nvidia │ │ │ ├── nvidia_support_since_docker_version_1903.py │ │ │ └── legacy_nvidia_support.py │ │ ├── multimedia_support.py │ │ ├── theme_support.py │ │ ├── go_support.py │ │ ├── rust_support.py │ │ ├── debug_gdb_support.py │ │ ├── nvidia_support.py │ │ └── docker_config.py │ ├── test.py │ ├── clean_build.py │ ├── screenshots.py │ ├── base.py │ ├── click_build.py │ ├── no_lock.py │ ├── devices.py │ ├── setup.py │ ├── run.py │ ├── writable_image.py │ ├── log.py │ ├── logs.py │ ├── update.py │ ├── ide.py │ ├── review.py │ ├── clean.py │ ├── launch.py │ ├── clean_libs.py │ ├── test_libs.py │ ├── create.py │ ├── build_libs.py │ ├── install.py │ ├── publish.py │ ├── gdb.py │ ├── shell.py │ └── build.py ├── exceptions.py ├── system │ ├── access.py │ ├── query.py │ ├── requirements │ │ ├── nvidia_modprobe.py │ │ ├── nvidia_container_toolkit.py │ │ └── nvidia_docker.py │ ├── require.py │ └── queries │ │ ├── nvidia_drivers_in_use.py │ │ └── legacy_docker_version.py ├── logger.py ├── device.py └── utils.py ├── .coveragerc ├── setup.cfg ├── clickable-dev ├── scripts ├── docker_test.sh ├── publish_pypi.sh ├── build_deb.sh └── publish_deb.sh ├── .gitignore ├── BASH_COMPLETION.md ├── MANIFEST.in ├── bash_completion ├── logo └── clickable.svg ├── setup.py ├── .gitlab-ci.yml └── README.md /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clickable/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clickable/builders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clickable/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /clickable/commands/idedelegates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | show_missing = True 3 | -------------------------------------------------------------------------------- /docs/_static/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6.24.1" 3 | } 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-coverage=1 3 | cover-package=clickable 4 | -------------------------------------------------------------------------------- /docs/autobuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sphinx-autobuild . _build/html/ 4 | -------------------------------------------------------------------------------- /clickable-dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import clickable 4 | clickable.main() 5 | -------------------------------------------------------------------------------- /docs/_static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhdouglass/clickable/HEAD/docs/_static/images/logo.png -------------------------------------------------------------------------------- /scripts/docker_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run -v `pwd`:`pwd` -w `pwd` clickable/testing nosetests 4 | -------------------------------------------------------------------------------- /scripts/publish_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf dist 4 | python3 setup.py sdist bdist_wheel 5 | python3 -m twine upload dist/* 6 | -------------------------------------------------------------------------------- /clickable/exceptions.py: -------------------------------------------------------------------------------- 1 | class ClickableException(Exception): 2 | pass 3 | 4 | 5 | class FileNotFoundException(ClickableException): 6 | pass 7 | -------------------------------------------------------------------------------- /clickable/system/access.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | 4 | def is_program_installed(cmd): 5 | path = shutil.which(cmd) 6 | return path is not None 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | parts 2 | prime 3 | stage 4 | tmp 5 | _build 6 | build 7 | *.egg-info/ 8 | __pycache__ 9 | .pybuild 10 | .coverage 11 | *.egg 12 | dist 13 | -------------------------------------------------------------------------------- /clickable/commands/idedelegates/qtcreator/QtProject.tar.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhdouglass/clickable/HEAD/clickable/commands/idedelegates/qtcreator/QtProject.tar.xz -------------------------------------------------------------------------------- /clickable/commands/idedelegates/idedelegate.py: -------------------------------------------------------------------------------- 1 | 2 | class IdeCommandDelegate: 3 | 4 | def override_command(self, path): 5 | pass 6 | 7 | def before_run(self, config, docker_config): 8 | pass 9 | -------------------------------------------------------------------------------- /clickable/system/query.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class Query(ABC): 5 | @abstractmethod 6 | def is_met(self): 7 | pass 8 | 9 | @abstractmethod 10 | def get_user_instructions(self): 11 | pass 12 | -------------------------------------------------------------------------------- /clickable/builders/base.py: -------------------------------------------------------------------------------- 1 | class Builder(object): 2 | name = None 3 | 4 | def __init__(self, config, device): 5 | self.config = config 6 | self.device = device 7 | 8 | def build(self): 9 | raise NotImplementedError() 10 | -------------------------------------------------------------------------------- /scripts/build_deb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RELEASE=${1:-bionic} 4 | 5 | docker run \ 6 | -v `pwd`/../:`pwd`/../ \ 7 | -w `pwd` \ 8 | -u `id -u` \ 9 | --rm \ 10 | -it clickable/build-deb:$RELEASE \ 11 | bash -c "dpkg-buildpackage && dh_clean" 12 | -------------------------------------------------------------------------------- /clickable/commands/docker/docker_support.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from clickable.commands.docker.docker_config import DockerConfig 4 | 5 | 6 | class DockerSupport(ABC): 7 | @abstractmethod 8 | def update(self, docker_config: DockerConfig): 9 | pass 10 | -------------------------------------------------------------------------------- /clickable/builders/custom.py: -------------------------------------------------------------------------------- 1 | from .base import Builder 2 | from clickable.config.project import ProjectConfig 3 | from clickable.config.constants import Constants 4 | 5 | 6 | class CustomBuilder(Builder): 7 | name = Constants.CUSTOM 8 | 9 | def build(self): 10 | self.config.container.run_command(self.config.build) 11 | -------------------------------------------------------------------------------- /tests/unit/test_shell.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from clickable.commands.shell import ShellCommand 4 | from .base_test import UnitTest 5 | 6 | 7 | class TestShellCommand(UnitTest): 8 | def setUp(self): 9 | self.setUpConfig() 10 | self.command = ShellCommand(self.config) 11 | 12 | 13 | # TODO implement this 14 | -------------------------------------------------------------------------------- /tests/unit/test_publish.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from clickable.commands.publish import PublishCommand 4 | from .base_test import UnitTest 5 | 6 | 7 | class TestPublishCommand(UnitTest): 8 | def setUp(self): 9 | self.setUpConfig() 10 | self.command = PublishCommand(self.config) 11 | 12 | 13 | # TODO implement this 14 | -------------------------------------------------------------------------------- /BASH_COMPLETION.md: -------------------------------------------------------------------------------- 1 | # Completion for clickable 2 | As read in https://debian-administration.org/article/317/An_introduction_to_bash_completion_part_2 3 | 4 | ## Installation 5 | - Copy file into system 6 | 7 | `sudo cp bash_completion /etc/bash_completion.d/clickable` 8 | 9 | - Restart terminal or run 10 | 11 | `. /etc/bash_completion.d/clickable` 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import ConfigMock 2 | from .clickable import ClickableMock 3 | 4 | 5 | def empty_fn(*args, **kwargs): 6 | pass 7 | 8 | 9 | def true_fn(*args, **kwargs): 10 | return True 11 | 12 | 13 | def false_fn(*args, **kwargs): 14 | return False 15 | 16 | 17 | def exception_fn(*args, **kwargs): 18 | raise Exception() 19 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block extrahead %} 3 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /clickable/commands/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from .base import Command 5 | 6 | 7 | class TestCommand(Command): 8 | aliases = [] 9 | name = 'test' 10 | help = 'Run tests on a virtual screen' 11 | 12 | def run(self, path_arg=None): 13 | command = 'xvfb-startup {}'.format(self.config.test) 14 | 15 | self.config.container.run_command(command, use_build_dir=False) 16 | -------------------------------------------------------------------------------- /clickable/system/requirements/nvidia_modprobe.py: -------------------------------------------------------------------------------- 1 | from clickable.system.access import is_program_installed 2 | from clickable.system.query import Query 3 | 4 | 5 | class NvidiaModprobe(Query): 6 | 7 | def is_met(self): 8 | return is_program_installed('nvidia-modprobe') 9 | 10 | def get_user_instructions(self): 11 | return ("You are running clickable in nvidia mode.\n" 12 | "Please install nvidia-modprobe.\n") 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include clickable/clickable.schema 4 | 5 | recursive-include debian * 6 | recursive-include docs * 7 | recursive-include clickable/builders * 8 | recursive-include clickable/commands * 9 | recursive-include clickable/system * 10 | recursive-include clickable/config * 11 | 12 | recursive-exclude scripts * 13 | recursive-exclude * __pycache__ 14 | recursive-exclude * *.py[co] 15 | recursive-exclude tests * 16 | -------------------------------------------------------------------------------- /clickable/commands/clean_build.py: -------------------------------------------------------------------------------- 1 | from .clean import CleanCommand 2 | from .build import BuildCommand 3 | from clickable.logger import logger 4 | 5 | 6 | class CleanBuildCommand(CleanCommand, BuildCommand): 7 | aliases = [] 8 | name = 'clean-build' 9 | help = 'Clean the build directory before compiling the app' 10 | 11 | def run(self, path_arg=None): 12 | CleanCommand.run(self, path_arg) 13 | BuildCommand.run(self, path_arg) 14 | -------------------------------------------------------------------------------- /clickable/commands/screenshots.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.utils import run_subprocess_check_call 3 | 4 | 5 | class WritableImageCommand(Command): 6 | aliases = [] 7 | name = 'screenshots' 8 | help = 'Download all the screenshots from the device' 9 | 10 | def run(self, path_arg=None): 11 | command = 'adb pull /home/phablet/Pictures/Screenshots' 12 | run_subprocess_check_call(command, cwd=self.config.cwd) 13 | -------------------------------------------------------------------------------- /tests/unit/test_desktop.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from clickable.commands.desktop import DesktopCommand 4 | from .base_test import UnitTest 5 | 6 | 7 | class TestCreateCommand(UnitTest): 8 | def setUp(self): 9 | self.setUpConfig() 10 | self.command = DesktopCommand(self.config) 11 | 12 | # TODO test that `CLICKABLE_NVIDIA=1 clickable desktop` yields the same command as `clickable desktop --nvidia` 13 | # TODO implement this 14 | -------------------------------------------------------------------------------- /clickable/commands/idedelegates/qtcreator/Readme.md: -------------------------------------------------------------------------------- 1 | # 2 | QtProject.tar.xz is just a backup of settings files generated by qtcreator the first time qtcreator is launched, with a little tweak: 3 | As qtcreator generates a default kit with a random id by default (in QtProject/qtcreator/profiles.xml ), the aim is to overwrite it manually with a more fiendly name ( here clickable-kit) 4 | so that we can bind kit with build/run configurations in projects .shared file 5 | -------------------------------------------------------------------------------- /clickable/commands/base.py: -------------------------------------------------------------------------------- 1 | from clickable.device import Device 2 | 3 | 4 | class Command(object): 5 | aliases = [] 6 | name = None 7 | help = '' 8 | 9 | def __init__(self, config): 10 | self.config = config 11 | self.device = Device(config) 12 | 13 | def run(self, path_arg=None): 14 | raise NotImplementedError('run is not yet implemeneted') 15 | 16 | def preprocess(self, path_arg=None): 17 | pass # Implemented in subclasses 18 | -------------------------------------------------------------------------------- /clickable/system/requirements/nvidia_container_toolkit.py: -------------------------------------------------------------------------------- 1 | from clickable.system.access import is_program_installed 2 | from clickable.system.query import Query 3 | 4 | 5 | class NvidiaContainerToolkit(Query): 6 | 7 | def is_met(self): 8 | return is_program_installed('nvidia-container-toolkit') 9 | 10 | def get_user_instructions(self): 11 | return ("You are running clickable in nvidia mode.\n" 12 | "Please install nvidia-container-toolkit.\n") 13 | -------------------------------------------------------------------------------- /clickable/commands/click_build.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class ClickBuildCommand(Command): 6 | aliases = ['build_click', 'build-click', 'click_build'] 7 | name = 'click-build' 8 | help = 'Deprecated' 9 | 10 | def run(self, path_arg=None): 11 | logger.warning('The click-build command has been merged into the build command. Please remove this command from your CI, as this warning will be removed in a future version.') 12 | -------------------------------------------------------------------------------- /clickable/commands/no_lock.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class NoLockCommand(Command): 6 | aliases = ['no_lock'] 7 | name = 'no-lock' 8 | help = 'Turns off the device’s display timeout' 9 | 10 | def run(self, path_arg=None): 11 | logger.info('Turning off device activity timeout') 12 | command = 'gsettings set com.ubuntu.touch.system activity-timeout 0' 13 | self.device.run_command(command, cwd=self.config.cwd) 14 | -------------------------------------------------------------------------------- /clickable/commands/devices.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class DevicesCommand(Command): 6 | aliases = [] 7 | name = 'devices' 8 | help = 'Lists all connected devices' 9 | 10 | def run(self, path_arg=None): 11 | devices = self.device.detect_attached() 12 | 13 | if len(devices) == 0: 14 | logger.warning('No attached devices') 15 | else: 16 | for device in devices: 17 | logger.info(device) 18 | -------------------------------------------------------------------------------- /clickable/commands/docker/debug_valgrind_support.py: -------------------------------------------------------------------------------- 1 | from clickable import ProjectConfig 2 | from clickable.commands.docker.docker_config import DockerConfig 3 | from .docker_support import DockerSupport 4 | 5 | 6 | class DebugValgrindSupport(DockerSupport): 7 | config = None 8 | 9 | def __init__(self, config: ProjectConfig): 10 | self.config = config 11 | 12 | def update(self, docker_config: DockerConfig): 13 | if self.config.debug_valgrind: 14 | docker_config.execute = 'valgrind {}'.format(docker_config.execute) 15 | -------------------------------------------------------------------------------- /clickable/system/requirements/nvidia_docker.py: -------------------------------------------------------------------------------- 1 | from clickable.system.access import is_program_installed 2 | from clickable.system.query import Query 3 | 4 | 5 | class NvidiaDocker(Query): 6 | 7 | def is_met(self): 8 | return is_program_installed('nvidia-docker') 9 | 10 | def get_user_instructions(self): 11 | return ("You are running clickable in nvidia mode.\n" 12 | "Please install nvidia-docker version 1 (not version 2).\n" 13 | "See https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(version-1.0).\n") 14 | -------------------------------------------------------------------------------- /clickable/system/require.py: -------------------------------------------------------------------------------- 1 | from clickable.logger import logger 2 | from .query import Query 3 | 4 | 5 | class Require(object): 6 | def __init__(self, query: Query): 7 | self.query = query 8 | 9 | def or_exit(self): 10 | if not self.query.is_met(): 11 | self.print_instructions() 12 | logger.error('System requirement not met') 13 | exit(1) 14 | 15 | def print_instructions(self): 16 | instructions = self.query.get_user_instructions() 17 | if instructions is not None: 18 | logger.warning(instructions) 19 | -------------------------------------------------------------------------------- /clickable/commands/setup.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | from clickable.exceptions import ClickableException 4 | 5 | 6 | class SetupCommand(Command): 7 | aliases = [] 8 | name = 'setup' 9 | help = 'Setup docker initially' 10 | 11 | def run(self, path_arg=None): 12 | try: 13 | self.config.container.run_command("echo ''", use_build_dir=False) 14 | logger.info('Clickable is set up and ready.') 15 | except ClickableException: 16 | logger.warning('Please log out or restart to apply changes') 17 | -------------------------------------------------------------------------------- /tests/unit/test_no_lock.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.no_lock import NoLockCommand 5 | from ..mocks import empty_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestNoLockCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpConfig() 12 | self.command = NoLockCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 15 | def test_no_lock(self, mock_run_command): 16 | self.command.run() 17 | 18 | mock_run_command.assert_called_once_with(ANY, cwd=ANY) 19 | -------------------------------------------------------------------------------- /clickable/commands/run.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.exceptions import ClickableException 3 | 4 | 5 | class RunCommand(Command): 6 | aliases = [] 7 | name = 'run' 8 | help = 'Runs an arbitrary command in the clickable container' 9 | 10 | def run(self, path_arg=None): 11 | cmd = path_arg 12 | if not cmd: 13 | cmd = "bash" 14 | 15 | self.config.container.setup() 16 | self.config.container.run_command( 17 | cmd, 18 | use_build_dir=False, 19 | tty=True, 20 | localhost=True, 21 | root_user=True, 22 | ) 23 | -------------------------------------------------------------------------------- /clickable/system/queries/nvidia_drivers_in_use.py: -------------------------------------------------------------------------------- 1 | from clickable.system.access import is_program_installed 2 | from clickable.system.query import Query 3 | from clickable.utils import run_subprocess_check_output 4 | 5 | class NvidiaDriversInUse(Query): 6 | def is_met(self): 7 | if not is_program_installed('nvidia-smi'): 8 | return False 9 | 10 | modules = run_subprocess_check_output('lsmod').splitlines() 11 | for m in modules: 12 | if m.split(' ', 1)[0] == 'nvidia': 13 | return True 14 | 15 | return False 16 | 17 | def get_user_instructions(self): 18 | return None 19 | -------------------------------------------------------------------------------- /tests/unit/test_writable_image.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.writable_image import WritableImageCommand 5 | from ..mocks import empty_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestWritableImageCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpConfig() 12 | self.command = WritableImageCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 15 | def test_writable_image(self, mock_run_command): 16 | self.command.run() 17 | 18 | mock_run_command.assert_called_once_with(ANY, cwd=ANY) 19 | -------------------------------------------------------------------------------- /clickable/commands/writable_image.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class WritableImageCommand(Command): 6 | aliases = ['writable_image', 'writeable-image'] 7 | name = 'writable-image' 8 | help = 'Make your Ubuntu Touch device\'s rootfs writable' 9 | 10 | def run(self, path_arg=None): 11 | command = 'dbus-send --system --print-reply --dest=com.canonical.PropertyService /com/canonical/PropertyService com.canonical.PropertyService.SetProperty string:writable boolean:true' 12 | self.device.run_command(command, cwd=self.config.cwd) 13 | logger.info('Rebooting device for writable image') 14 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | include /usr/share/dpkg/pkg-info.mk 3 | PKGDIR=debian/clickable 4 | 5 | %: 6 | dh $@ --with python3 --buildsystem=pybuild 7 | 8 | override_dh_install: 9 | dh_install 10 | 11 | # Install the manpage 12 | mkdir -p $(PKGDIR)/usr/share/man/man1/ 13 | 14 | PYTHONPATH=$(PKGDIR)/usr/lib/$(shell ls $(PKGDIR)/usr/lib/)/dist-packages/ \ 15 | help2man $(PKGDIR)/usr/bin/clickable \ 16 | --no-info \ 17 | --help-option="--help" \ 18 | --version-string=$(DEB_VERSION_UPSTREAM) \ 19 | -o $(PKGDIR)/usr/share/man/man1/clickable.1 20 | 21 | override_dh_auto_test: 22 | # TODO Temporarily ignore test failures 23 | dh_auto_test || true 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = clickable 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /clickable/commands/docker/webapp_support.py: -------------------------------------------------------------------------------- 1 | from clickable.commands.docker.docker_config import DockerConfig 2 | from .docker_support import DockerSupport 3 | 4 | 5 | class WebappSupport(DockerSupport): 6 | package_name = '' 7 | 8 | def __init__(self, package_name): 9 | self.package_name = package_name 10 | 11 | def update(self, docker_config: DockerConfig): 12 | # changes docker config if Exec=webapp-container 13 | if self.is_executable_webapp_container(docker_config): 14 | docker_config.add_environment_variables({'APP_ID': self.package_name}) 15 | 16 | def is_executable_webapp_container(self, docker_config): 17 | return docker_config.execute.startswith('webapp-container') 18 | -------------------------------------------------------------------------------- /clickable/commands/docker/nvidia/nvidia_support_since_docker_version_1903.py: -------------------------------------------------------------------------------- 1 | from clickable.commands.docker.docker_config import DockerConfig 2 | from clickable.commands.docker.docker_support import DockerSupport 3 | from clickable.system.require import Require 4 | from clickable.system.requirements.nvidia_container_toolkit import NvidiaContainerToolkit 5 | 6 | 7 | class NvidiaSupportSinceDockerVersion1903(DockerSupport): 8 | def update(self, docker_config: DockerConfig): 9 | self.validate_system_requirements_are_met() 10 | docker_config.add_extra_options({ 11 | '--gpus': 'all', 12 | }) 13 | 14 | def validate_system_requirements_are_met(self): 15 | Require(NvidiaContainerToolkit()).or_exit() 16 | -------------------------------------------------------------------------------- /clickable/commands/docker/nvidia/legacy_nvidia_support.py: -------------------------------------------------------------------------------- 1 | from clickable.commands.docker.docker_config import DockerConfig 2 | from clickable.commands.docker.docker_support import DockerSupport 3 | from clickable.system.require import Require 4 | from clickable.system.requirements.nvidia_docker import NvidiaDocker 5 | from clickable.system.requirements.nvidia_modprobe import NvidiaModprobe 6 | 7 | 8 | class LegacyNvidiaSupport(DockerSupport): 9 | def update(self, docker_config: DockerConfig): 10 | self.validate_system_requirements_are_met() 11 | docker_config.docker_executable = 'nvidia-docker' 12 | 13 | def validate_system_requirements_are_met(self): 14 | Require(NvidiaModprobe()).or_exit() 15 | Require(NvidiaDocker()).or_exit() 16 | 17 | -------------------------------------------------------------------------------- /docs/continuous-integration.rst: -------------------------------------------------------------------------------- 1 | .. _continuous-integration: 2 | 3 | Continuous Integration 4 | ====================== 5 | 6 | Clickable CI Docker Images 7 | -------------------------- 8 | 9 | Two docker images are available for easily using Clickable with a continuous 10 | integration setup. They can be found on Docker hub: ``clickable/ci-16.04-armhf`` 11 | and ``clickable/ci-16.04-amd64``. The images come with Clickable pre installed 12 | and already setup in container mode. 13 | 14 | GitLab CI Tutorial 15 | ------------------ 16 | 17 | For a full guide to setting up GitLab CI with a Clickable app check out this 18 | `blog post `__. 19 | This method can also be adapted for other CI solutions. 20 | -------------------------------------------------------------------------------- /clickable/commands/log.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class LogCommand(Command): 6 | aliases = [] 7 | name = 'log' 8 | help = 'Outputs the app\'s log from the device' 9 | 10 | def run(self, path_arg=None): 11 | if self.config.is_desktop_mode(): 12 | logger.debug('Skipping log, running in desktop mode') 13 | return 14 | elif self.config.container_mode: 15 | logger.debug('Skipping log, running in container mode') 16 | return 17 | 18 | log = '~/.cache/upstart/application-click-{}.log'.format( 19 | self.config.install_files.find_full_package_name(), 20 | ) 21 | 22 | if self.config.log: 23 | log = self.config.log 24 | 25 | self.device.run_command('cat {}'.format(log)) 26 | -------------------------------------------------------------------------------- /clickable/commands/logs.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class LogsCommand(Command): 6 | aliases = [] 7 | name = 'logs' 8 | help = 'Follow the app\'s log file on the device' 9 | 10 | def run(self, path_arg=None): 11 | if self.config.is_desktop_mode(): 12 | logger.debug('Skipping logs, running in desktop mode') 13 | return 14 | elif self.config.container_mode: 15 | logger.debug('Skipping logs, running in container mode') 16 | return 17 | 18 | log = '~/.cache/upstart/application-click-{}.log'.format( 19 | self.config.install_files.find_full_package_name(), 20 | ) 21 | 22 | if self.config.log: 23 | log = self.config.log 24 | 25 | self.device.run_command('tail -f {}'.format(log)) 26 | -------------------------------------------------------------------------------- /clickable/commands/update.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | 4 | from clickable.config.constants import Constants 5 | from .base import Command 6 | from clickable.utils import ( 7 | run_subprocess_check_call, 8 | run_subprocess_check_output, 9 | image_exists, 10 | ) 11 | 12 | def update_image(image): 13 | if image_exists(image): 14 | command = 'docker pull {}'.format(image) 15 | run_subprocess_check_call(command) 16 | 17 | 18 | class UpdateCommand(Command): 19 | aliases = ['update_docker', 'update-docker'] 20 | name = 'update' 21 | help = 'Update the docker container for use with clickable' 22 | 23 | def run(self, path_arg=None): 24 | self.config.container.check_docker() 25 | 26 | container_mapping = Constants.container_mapping[self.config.host_arch] 27 | for image in container_mapping.values(): 28 | update_image(image) 29 | -------------------------------------------------------------------------------- /clickable/commands/docker/multimedia_support.py: -------------------------------------------------------------------------------- 1 | from clickable import ProjectConfig 2 | from clickable.commands.docker.docker_config import DockerConfig 3 | from .docker_support import DockerSupport 4 | import os 5 | import getpass 6 | 7 | class MultimediaSupport(DockerSupport): 8 | config = None 9 | 10 | def __init__(self, config: ProjectConfig): 11 | self.config = config 12 | 13 | def update(self, docker_config: DockerConfig): 14 | uid = os.getuid() 15 | user = getpass.getuser() 16 | 17 | docker_config.volumes.update({ 18 | '/dev/shm': '/dev/shm', 19 | '/etc/machine-id': '/etc/machine-id', 20 | '/run/{}/pulse'.format(uid): '/run/user/1000/pulse', 21 | '/var/lib/dbus': '/var/lib/dbus', 22 | '/home/{}/.pulse'.format(user): '/home/phablet/.pulse', 23 | '/dev/snd': '/dev/snd', 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: clickable 2 | Section: devel 3 | Priority: optional 4 | Maintainer: JBBgameich 5 | Build-Depends: debhelper (>= 9), 6 | dh-python, 7 | dpkg-dev, 8 | help2man, 9 | python3 (>= 3.3), 10 | python3-requests, 11 | python3-cookiecutter, 12 | python3-jsonschema, 13 | python3-setuptools 14 | Standards-Version: 4.1.4 15 | Homepage: https://gitlab.com/clickable/clickable 16 | X-Python3-Version: >= 3.3 17 | 18 | Package: clickable 19 | Architecture: all 20 | Depends: adb | android-tools-adb, 21 | docker.io | docker-ce, 22 | python3-requests, 23 | python3-jsonschema, 24 | ${misc:Depends}, 25 | ${python3:Depends} 26 | Recommends: python3-distutils 27 | Suggests: x11-xserver-utils 28 | Description: Compile, build, and deploy Ubuntu Touch click packages all from the command line. 29 | -------------------------------------------------------------------------------- /clickable/commands/docker/theme_support.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from clickable.commands.docker.docker_config import DockerConfig 4 | from clickable.utils import makedirs 5 | from clickable.config.constants import Constants 6 | from .docker_support import DockerSupport 7 | 8 | 9 | class ThemeSupport(DockerSupport): 10 | config = None 11 | package_name = '' 12 | 13 | def __init__(self, config): 14 | self.config = config 15 | 16 | def update(self, docker_config: DockerConfig): 17 | package_name = self.config.install_files.find_package_name() 18 | 19 | config_path = makedirs(os.path.join(Constants.desktop_device_home, '.config/ubuntu-ui-toolkit')) 20 | 21 | theme = 'Ubuntu.Components.Themes.Ambiance' 22 | if self.config.dark_mode: 23 | theme = 'Ubuntu.Components.Themes.SuruDark' 24 | 25 | with open(os.path.join(config_path, 'theme.ini'), 'w') as f: 26 | f.write('[General]\ntheme=' + theme) 27 | -------------------------------------------------------------------------------- /tests/unit/test_review.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from clickable.commands.review import ReviewCommand 4 | from ..mocks import empty_fn 5 | from .base_test import UnitTest 6 | 7 | 8 | class TestReviewCommand(UnitTest): 9 | def setUp(self): 10 | self.setUpWithTmpBuildDir() 11 | self.command = ReviewCommand(self.config) 12 | 13 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 14 | def test_run(self, mock_run_command): 15 | self.command.run() 16 | 17 | mock_run_command.assert_called_once_with('click-review /tmp/build/foo.bar_1.2.3_armhf.click', cwd='/tmp/build', use_build_dir=False) 18 | 19 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 20 | def test_run_with_path_arg(self, mock_run_command): 21 | self.command.run('/foo/bar.click') 22 | 23 | mock_run_command.assert_called_once_with('click-review /foo/bar.click', cwd='/foo', use_build_dir=False) 24 | -------------------------------------------------------------------------------- /clickable/builders/cmake.py: -------------------------------------------------------------------------------- 1 | from .make import MakeBuilder 2 | from clickable.config.project import ProjectConfig 3 | from clickable.config.constants import Constants 4 | 5 | 6 | class CMakeBuilder(MakeBuilder): 7 | name = Constants.CMAKE 8 | 9 | def make_install(self): 10 | super().make_install() 11 | 12 | self.config.container.run_command('make DESTDIR={}/ install'.format(self.config.install_dir)) 13 | 14 | def build(self): 15 | command = 'cmake' 16 | 17 | if self.config.build_args: 18 | command = '{} {}'.format(command, ' '.join(self.config.build_args)) 19 | 20 | if self.config.debug_build: 21 | command = '{} {}'.format(command, '-DCMAKE_BUILD_TYPE=Debug') 22 | else: 23 | command = '{} {}'.format(command, '-DCMAKE_BUILD_TYPE=Release') 24 | 25 | self.config.container.run_command('{} {} -DCMAKE_INSTALL_PREFIX:PATH=/.'.format(command, self.config.src_dir)) 26 | 27 | super().build() 28 | -------------------------------------------------------------------------------- /scripts/publish_deb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | 6 | function docker_run { 7 | docker run \ 8 | -v `pwd`/../:`pwd`/../ \ 9 | -v $HOME/.gnupg/:$HOME/.gnupg/ \ 10 | -w `pwd` \ 11 | -u `id -u` \ 12 | -e HOME=$HOME \ 13 | -e USER=$USER \ 14 | --rm \ 15 | -it clickable/build-deb $1 16 | } 17 | 18 | function publish { 19 | rm -f ../clickable_* 20 | 21 | # Prepare for upload and build source package 22 | sed -i "s/) unstable/~$1) $1/g" debian/changelog 23 | #sed -i "s/unstable/$1/g" debian/changelog 24 | docker_run "debuild -S" 25 | docker_run "dput ppa:bhdouglass/clickable ../clickable_*_source.changes" 26 | 27 | # Clean up 28 | docker_run "dh_clean" 29 | #sed -i "s/$1/unstable/g" debian/changelog 30 | sed -i "s/~$1) $1/) unstable/g" debian/changelog 31 | } 32 | 33 | publish trusty 34 | publish xenial 35 | publish bionic 36 | publish focal 37 | publish groovy 38 | publish hirsute 39 | -------------------------------------------------------------------------------- /clickable/commands/docker/go_support.py: -------------------------------------------------------------------------------- 1 | from clickable import ProjectConfig 2 | from clickable.config.constants import Constants 3 | from clickable.commands.docker.docker_config import DockerConfig 4 | from .docker_support import DockerSupport 5 | 6 | 7 | class GoSupport(DockerSupport): 8 | config = None 9 | 10 | def __init__(self, config: ProjectConfig): 11 | self.config = config 12 | 13 | def update(self, docker_config: DockerConfig): 14 | builder = self.config.builder 15 | 16 | if builder == Constants.GO: 17 | go_paths = list(map( 18 | lambda gopath: 19 | '/gopath/path{}'.format(gopath), 20 | self.config.gopath.split(':') 21 | )) 22 | 23 | for path in go_paths: 24 | docker_config.add_volume_mappings({ 25 | path: path 26 | }) 27 | 28 | docker_config.add_environment_variables({ 29 | 'GOPATH': ':'.join(list(go_paths)) 30 | }) 31 | -------------------------------------------------------------------------------- /clickable/commands/ide.py: -------------------------------------------------------------------------------- 1 | from .desktop import DesktopCommand 2 | from clickable.logger import logger 3 | from clickable.exceptions import ClickableException 4 | from .idedelegates.qtcreator import QtCreatorDelegate 5 | 6 | class IdeCommand(DesktopCommand): 7 | aliases = [] 8 | name = 'ide' 9 | help = 'Run a custom command in desktop mode (e.g. an IDE)' 10 | 11 | def __init__(self, config): 12 | super().__init__(config) 13 | self.custom_mode = True 14 | 15 | def run(self, path_arg=None): 16 | if not path_arg: 17 | raise ClickableException('No command supplied for `clickable ide`') 18 | 19 | #get the preprocessor according to command if any 20 | if 'qtcreator' in path_arg.split(): 21 | self.ide_delegate = QtCreatorDelegate() 22 | path_arg = self.ide_delegate.override_command(path_arg) 23 | logger.debug('QtCreator command detected. Changing command to: {}'.format(path_arg)) 24 | 25 | self.command = path_arg 26 | super().run() 27 | -------------------------------------------------------------------------------- /clickable/commands/docker/rust_support.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from clickable.commands.docker.docker_config import DockerConfig 4 | from clickable.config.project import ProjectConfig 5 | from clickable.config.constants import Constants 6 | from .docker_support import DockerSupport 7 | 8 | 9 | class RustSupport(DockerSupport): 10 | config = None 11 | 12 | def __init__(self, config: ProjectConfig): 13 | self.config = config 14 | 15 | def update(self, docker_config: DockerConfig): 16 | builder = self.config.builder 17 | 18 | if builder == Constants.RUST: 19 | cargo_home = self.config.cargo_home 20 | cargo_registry = os.path.join(cargo_home, 'registry') 21 | cargo_git = os.path.join(cargo_home, 'git') 22 | 23 | os.makedirs(cargo_registry, exist_ok=True) 24 | os.makedirs(cargo_git, exist_ok=True) 25 | 26 | docker_config.add_volume_mappings({ 27 | cargo_registry: '/opt/rust/cargo/registry', 28 | cargo_git: '/opt/rust/cargo/git' 29 | }) 30 | -------------------------------------------------------------------------------- /tests/unit/test_update.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.update import UpdateCommand 5 | from ..mocks import empty_fn 6 | from .base_test import UnitTest 7 | 8 | import subprocess 9 | 10 | def zero_fn(*args, **kwargs): 11 | return 0 12 | 13 | 14 | class TestUpdateCommand(UnitTest): 15 | def setUp(self): 16 | self.setUpConfig() 17 | self.command = UpdateCommand(self.config) 18 | 19 | @mock.patch('clickable.container.Container.check_docker', side_effect=empty_fn) 20 | @mock.patch('clickable.utils.run_subprocess_call', side_effect=zero_fn) 21 | @mock.patch('clickable.commands.update.run_subprocess_check_call', side_effect=empty_fn) 22 | def test_update(self, mock_run_subprocess_check_call, mock_run_subprocess_call, mock_check_docker): 23 | self.command.run() 24 | 25 | mock_check_docker.assert_called_once_with() 26 | mock_run_subprocess_check_call.assert_called_with(ANY) 27 | mock_run_subprocess_call.assert_called_with(ANY, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) 28 | -------------------------------------------------------------------------------- /clickable/commands/docker/debug_gdb_support.py: -------------------------------------------------------------------------------- 1 | from clickable import ProjectConfig 2 | from clickable.commands.docker.docker_config import DockerConfig 3 | from .docker_support import DockerSupport 4 | 5 | 6 | class DebugGdbSupport(DockerSupport): 7 | config = None 8 | 9 | def __init__(self, config: ProjectConfig): 10 | self.config = config 11 | 12 | def update(self, docker_config: DockerConfig): 13 | if self.config.debug_gdb: 14 | if self.config.debug_gdb_port: 15 | port = self.config.debug_gdb_port 16 | docker_config.execute = 'gdbserver localhost:{} {}'.format(port, docker_config.execute) 17 | docker_config.add_extra_options({ 18 | '--publish': '{port}:{port}'.format(port=port) 19 | }) 20 | else: 21 | docker_config.execute = 'gdb --args {}'.format(docker_config.execute) 22 | docker_config.add_extra_options({ 23 | '--cap-add': 'SYS_PTRACE', 24 | '--security-opt seccomp': 'unconfined' 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /tests/unit/test_build_libs.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.build_libs import LibBuildCommand 5 | from .base_test import UnitTest 6 | from ..mocks import empty_fn, false_fn 7 | 8 | 9 | class TestLibBuildCommand(UnitTest): 10 | def setUp(self): 11 | self.custom_cmd = 'echo "Building lib"' 12 | 13 | config_json = {} 14 | config_json["libraries"] = { 15 | "testlib": { 16 | 'builder': 'custom', 17 | 'build': self.custom_cmd, 18 | } 19 | } 20 | self.setUpConfig(mock_config_json = config_json) 21 | self.command = LibBuildCommand(self.config) 22 | 23 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 24 | @mock.patch('os.makedirs', side_effect=empty_fn) 25 | def test_click_build(self, mock_makedirs, mock_run_command): 26 | self.command.run() 27 | 28 | mock_run_command.assert_called_once_with(self.custom_cmd) 29 | mock_makedirs.assert_called_with(ANY, exist_ok=True) 30 | 31 | # TODO implement more 32 | -------------------------------------------------------------------------------- /clickable/commands/docker/nvidia_support.py: -------------------------------------------------------------------------------- 1 | from clickable.commands.docker.docker_config import DockerConfig 2 | from clickable.system.queries.legacy_docker_version import LegacyDockerVersion 3 | from clickable.system.queries.nvidia_drivers_in_use import NvidiaDriversInUse 4 | from .docker_support import DockerSupport 5 | from .nvidia.legacy_nvidia_support import LegacyNvidiaSupport 6 | from .nvidia.nvidia_support_since_docker_version_1903 import NvidiaSupportSinceDockerVersion1903 7 | 8 | 9 | class NvidiaSupport(DockerSupport): 10 | def update(self, docker_config: DockerConfig): 11 | if docker_config.use_nvidia: 12 | docker_config.add_volume_mappings({ 13 | '/dev/snd/pcmC2D0c': '/dev/snd/pcmC2D0c', 14 | '/dev/snd/controlC2': '/dev/snd/controlC2' 15 | }) 16 | docker_config.add_extra_options({ 17 | '--device': '/dev/snd', 18 | }) 19 | 20 | if LegacyDockerVersion().is_met(): 21 | LegacyNvidiaSupport().update(docker_config) 22 | return 23 | 24 | NvidiaSupportSinceDockerVersion1903().update(docker_config) 25 | -------------------------------------------------------------------------------- /tests/unit/test_create.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.create import CreateCommand 5 | from .base_test import UnitTest 6 | from ..mocks import empty_fn 7 | 8 | 9 | class TestCreateCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpConfig() 12 | 13 | @mock.patch('cookiecutter.main.cookiecutter', side_effect=empty_fn) 14 | def test_create_interactive(self, mock_cookiecutter): 15 | self.config.interactive = True 16 | command = CreateCommand(self.config) 17 | command.run() 18 | mock_cookiecutter.assert_called_with(ANY, config_file=ANY, 19 | extra_context={'Copyright Year': ANY}, no_input=False) 20 | 21 | @mock.patch('cookiecutter.main.cookiecutter', side_effect=empty_fn) 22 | def test_create_non_interactive(self, mock_cookiecutter): 23 | self.config.interactive = False 24 | command = CreateCommand(self.config) 25 | command.run() 26 | mock_cookiecutter.assert_called_with(ANY, config_file=ANY, 27 | extra_context={'Copyright Year': ANY}, no_input=True) 28 | 29 | # TODO add more 30 | -------------------------------------------------------------------------------- /clickable/commands/review.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .base import Command 5 | 6 | 7 | class ReviewCommand(Command): 8 | aliases = [] 9 | name = 'review' 10 | help = 'Takes the built click package and runs click-review against it' 11 | 12 | def check(self, path=None, raise_on_error=False, raise_on_warning=False): 13 | if path: 14 | click = os.path.basename(path) 15 | click_path = path 16 | else: 17 | click = self.config.install_files.get_click_filename() 18 | click_path = os.path.join(self.config.build_dir, click) 19 | 20 | cwd = os.path.dirname(os.path.realpath(click_path)) 21 | 22 | try: 23 | self.config.container.run_command('click-review {}'.format(click_path), use_build_dir=False, cwd=cwd) 24 | except subprocess.CalledProcessError as e: 25 | if e.returncode == 2 and not raise_on_error: 26 | pass 27 | elif e.returncode == 3 and not raise_on_warning: 28 | pass 29 | else: 30 | raise e 31 | 32 | def run(self, path_arg=None): 33 | self.check(path_arg) 34 | -------------------------------------------------------------------------------- /docs/app-templates.rst: -------------------------------------------------------------------------------- 1 | .. _app-templates: 2 | 3 | App Templates 4 | ============= 5 | 6 | Find the source code for the templates on `GitLab `__. 7 | 8 | QML Only 9 | -------- 10 | 11 | An app template that is setup for a purely QML app. It includes a CMake setup 12 | to allow for easy translations. 13 | 14 | C++ (Plugin) 15 | ------------ 16 | 17 | An app template that is setup for a QML app with a C++ plugin. It includes a CMake 18 | setup for compiling and to allow for easy translation. 19 | 20 | Python 21 | ------ 22 | 23 | An app template that is setup for an app using Python with QML. It includes a 24 | CMake setup to allow for easy translation. 25 | 26 | HTML 27 | ---- 28 | 29 | An app template that is setup for a local HTML app. 30 | 31 | Go 32 | -- 33 | 34 | An app template that is setup for a QML app with a Go backend. 35 | 36 | C++ (Binary) 37 | ------------ 38 | 39 | An app template that is setup for a QML app with a main.cpp to build a custom 40 | binary rather than relying on qmlscene. It includes a CMake setup for compiling 41 | to allow for easy translation. 42 | 43 | Rust 44 | ---- 45 | 46 | An app template that is setup for a QML app with a Rust backend. 47 | -------------------------------------------------------------------------------- /clickable/builders/qmake.py: -------------------------------------------------------------------------------- 1 | from .make import MakeBuilder 2 | from clickable.config.project import ProjectConfig 3 | from clickable.config.constants import Constants 4 | from clickable.exceptions import ClickableException 5 | 6 | class QMakeBuilder(MakeBuilder): 7 | name = Constants.QMAKE 8 | 9 | def make_install(self): 10 | super().make_install() 11 | 12 | self.config.container.run_command('make INSTALL_ROOT={}/ install'.format(self.config.install_dir)) 13 | 14 | def build(self): 15 | if self.config.arch == self.config.host_arch or self.config.qt_version == "5.9" or self.config.arch == "all": 16 | command = 'qmake' 17 | else: 18 | command = '/usr/lib/{}/qt5/bin/qmake'.format(self.config.arch_triplet) 19 | 20 | if self.config.build_args: 21 | command = '{} {}'.format(command, ' '.join(self.config.build_args)) 22 | 23 | if self.config.debug_build: 24 | command = '{} {}'.format(command, 'CONFIG+=debug') 25 | 26 | # user may have defined a specific .pro file, so qmake must not read others (if any) 27 | if not any(arg.endswith(".pro") for arg in self.config.build_args): 28 | command = '{} {}'.format(command, self.config.src_dir) 29 | 30 | self.config.container.run_command(command) 31 | 32 | super().build() 33 | -------------------------------------------------------------------------------- /tests/unit/test_devices.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.devices import DevicesCommand 5 | from ..mocks import empty_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | def no_devices(*args, **kwargs): 10 | return [] 11 | 12 | 13 | def devices(*args, **kwargs): 14 | return ['foo - bar'] 15 | 16 | 17 | class TestDevicesCommand(UnitTest): 18 | def setUp(self): 19 | self.setUpConfig() 20 | self.command = DevicesCommand(self.config) 21 | 22 | @mock.patch('clickable.device.Device.detect_attached', side_effect=no_devices) 23 | @mock.patch('clickable.commands.devices.logger.warning', side_effect=empty_fn) 24 | def test_no_devices(self, mock_logger_warning, mock_detect_attached): 25 | self.command.run() 26 | 27 | mock_detect_attached.assert_called_once_with() 28 | mock_logger_warning.assert_called_once_with('No attached devices') 29 | 30 | @mock.patch('clickable.device.Device.detect_attached', side_effect=devices) 31 | @mock.patch('clickable.commands.devices.logger.info', side_effect=empty_fn) 32 | def test_no_devices(self, mock_logger_info, mock_detect_attached): 33 | self.command.run() 34 | 35 | mock_detect_attached.assert_called_once_with() 36 | mock_logger_info.assert_called_once_with('foo - bar') 37 | -------------------------------------------------------------------------------- /tests/unit/test_run.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.run import RunCommand 5 | from clickable.exceptions import ClickableException 6 | from ..mocks import empty_fn 7 | from .base_test import UnitTest 8 | 9 | 10 | class TestRunCommand(UnitTest): 11 | def setUp(self): 12 | self.setUpConfig() 13 | self.command = RunCommand(self.config) 14 | 15 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 16 | @mock.patch('clickable.container.Container.setup', side_effect=empty_fn) 17 | def test_run(self, mock_setup, mock_run_command): 18 | self.command.run('echo foo') 19 | 20 | mock_setup.assert_called_once_with() 21 | mock_run_command.assert_called_once_with('echo foo', 22 | use_build_dir=False, tty=True, localhost=True, root_user=True) 23 | 24 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 25 | @mock.patch('clickable.container.Container.setup', side_effect=empty_fn) 26 | def test_run_default_command(self, mock_setup, mock_run_command): 27 | self.command.run() 28 | 29 | mock_setup.assert_called_once_with() 30 | mock_run_command.assert_called_once_with('bash', 31 | use_build_dir=False, tty=True, localhost=True, root_user=True) 32 | -------------------------------------------------------------------------------- /tests/mocks/clickable.py: -------------------------------------------------------------------------------- 1 | from clickable import Clickable 2 | from ..mocks import ConfigMock 3 | import os 4 | 5 | class ClickableMock(Clickable): 6 | def __init__(self, 7 | mock_config_json=None, 8 | mock_config_env=None, 9 | mock_install_files=False): 10 | self.mock_config_json = mock_config_json 11 | self.mock_config_env = mock_config_env 12 | self.mock_install_files = mock_install_files 13 | super().__init__() 14 | 15 | def setup_config(self, args, commands): 16 | container_mode_key = "CLICKABLE_CONTAINER_MODE" 17 | 18 | if (self.mock_config_env is not None and 19 | not container_mode_key in self.mock_config_env and 20 | container_mode_key in os.environ): 21 | self.mock_config_env[container_mode_key] = os.environ[container_mode_key] 22 | 23 | return ConfigMock( 24 | args=args, 25 | commands=commands, 26 | mock_config_json=self.mock_config_json, 27 | mock_config_env=self.mock_config_env, 28 | mock_install_files=self.mock_install_files, 29 | ) 30 | 31 | def run_clickable(self, cli_args=[]): 32 | parser = Clickable.create_parser("Integration Test Call") 33 | run_args = parser.parse_args(cli_args) 34 | self.run(run_args.commands, run_args) 35 | -------------------------------------------------------------------------------- /docs/builders.rst: -------------------------------------------------------------------------------- 1 | .. _builders: 2 | 3 | Builders 4 | ======== 5 | Builders have been called Build Templates in the early days of Clickable. 6 | 7 | pure-qml-qmake 8 | -------------- 9 | 10 | A purely qml qmake project. 11 | 12 | qmake 13 | ----- 14 | 15 | A project that builds using qmake (has more than just QML). 16 | 17 | pure-qml-cmake 18 | -------------- 19 | 20 | A purely qml cmake project 21 | 22 | cmake 23 | ----- 24 | 25 | A project that builds using cmake (has more than just QML) 26 | 27 | custom 28 | ------ 29 | 30 | A custom build command will be used. 31 | 32 | cordova 33 | ------- 34 | 35 | A project that builds using cordova 36 | 37 | pure 38 | ---- 39 | 40 | A project that does not need to be compiled. All files in the project root will be copied into the click. 41 | 42 | precompiled 43 | ----------- 44 | 45 | A project that does not need to be compiled. All files in the project root will 46 | be copied into the click. There may be precompiled binaries or libraries 47 | included in apps build with this builder. Specifying the 48 | :ref:`restrict_arch ` in the clickable.json file 49 | can be useful with this builder. 50 | 51 | python 52 | ------ 53 | 54 | Deprecated, use "precompiled" instead. 55 | 56 | go 57 | -- 58 | 59 | A project that uses go version 1.6. 60 | 61 | rust 62 | ---- 63 | 64 | A project that uses rust. Debug builds can be enabled by specifying ``--debug``. 65 | -------------------------------------------------------------------------------- /clickable/commands/clean.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | from .base import Command 6 | from clickable.logger import logger 7 | 8 | 9 | class CleanCommand(Command): 10 | aliases = [] 11 | name = 'clean' 12 | help = 'Clean the build directory' 13 | 14 | def run(self, path_arg=None): 15 | if os.path.exists(self.config.build_dir): 16 | try: 17 | shutil.rmtree(self.config.build_dir) 18 | except Exception: 19 | cls, value, traceback = sys.exc_info() 20 | if cls == OSError and 'No such file or directory' in str(value): # TODO see if there is a proper way to do this 21 | pass # Nothing to do here, the directory didn't exist 22 | else: 23 | logger.warning('Failed to clean the build directory: {}: {}'.format(type, value)) 24 | 25 | if os.path.exists(self.config.install_dir): 26 | try: 27 | shutil.rmtree(self.config.install_dir) 28 | except Exception: 29 | cls, value, traceback = sys.exc_info() 30 | if cls == OSError and 'No such file or directory' in str(value): # TODO see if there is a proper way to do this 31 | pass # Nothing to do here, the directory didn't exist 32 | else: 33 | logger.warning('Failed to clean the temp directory: {}: {}'.format(type, value)) 34 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Install 4 | ======= 5 | 6 | Install Via Pip (Recommended) 7 | ----------------------------- 8 | 9 | * Install docker, adb, git, python3 and pip3 10 | (in Ubuntu: ``sudo apt install docker.io adb git python3 python3-pip python3-setuptools``) 11 | * Run: ``pip3 install --user clickable-ut`` 12 | * Add pip scripts to your PATH: ``echo 'export PATH="$PATH:~/.local/bin"' >> ~/.bashrc`` and open a new terminal for the setting to take effect 13 | * Alternatively, to install nightly builds: ``pip3 install --user git+https://gitlab.com/clickable/clickable.git@dev`` 14 | 15 | To update Clickable via pip, run the same command as for installing, adding ``--upgrade``. 16 | 17 | Install Via PPA (Ubuntu) 18 | ------------------------ 19 | 20 | * Add the `PPA `__ to your system: ``sudo add-apt-repository ppa:bhdouglass/clickable`` 21 | * Update your package list: ``sudo apt-get update`` 22 | * Install clickable: ``sudo apt-get install clickable`` 23 | 24 | Install Via AUR (Arch Linux) 25 | ---------------------------- 26 | 27 | * Using your favorite AUR helper, install the `clickable-git package `__ 28 | * Example: ``pacaur -S clickable-git`` 29 | 30 | After install 31 | ============= 32 | 33 | * Let Clickable setup docker (it could ask for the sudo password): ``clickable setup`` 34 | * Log out or restart to apply changes 35 | -------------------------------------------------------------------------------- /clickable/builders/go.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | import os 4 | 5 | from .base import Builder 6 | from clickable.config.project import ProjectConfig 7 | from clickable.config.constants import Constants 8 | 9 | 10 | class GoBuilder(Builder): 11 | name = Constants.GO 12 | 13 | def _ignore(self, path, contents): 14 | ignored = [] 15 | for content in contents: 16 | cpath = os.path.abspath(os.path.join(path, content)) 17 | if ( 18 | cpath == os.path.abspath(self.config.install_dir) or 19 | cpath == os.path.abspath(self.config.build_dir) or 20 | content in self.config.ignore or 21 | content == 'clickable.json' or 22 | 23 | # Don't copy the go files, they will be compiled from the source directory 24 | os.path.splitext(content)[1] == '.go' 25 | ): 26 | ignored.append(content) 27 | 28 | return ignored 29 | 30 | def build(self): 31 | shutil.copytree(self.config.cwd, self.config.install_dir, ignore=self._ignore) 32 | 33 | gocommand = '/usr/local/go/bin/go build -pkgdir {cwd}/.clickable/go -i -o {install_dir}/{app_name} ../../..'.format( 34 | cwd=self.config.cwd, 35 | install_dir=self.config.install_dir, 36 | app_name=self.config.install_files.find_app_name(), 37 | ) 38 | self.config.container.run_command(gocommand) 39 | -------------------------------------------------------------------------------- /docs/debugging.rst: -------------------------------------------------------------------------------- 1 | .. _debugging-with-gdb: 2 | 3 | Debugging 4 | ========= 5 | 6 | Desktop Mode 7 | ------------ 8 | 9 | The easiest way to do GDB Debugging via Clickable is desktop mode and can be started 10 | by running ``clickable desktop --gdb``. 11 | 12 | Alternatively a GDB Server can be started with ``clickable desktop --gdbserver `` 13 | (just choose any port, e.g. ``3333``). Check for an option to do GDB Remote Debugging in your IDE 14 | and connect to ``localhost:``. To connect a GDB Client run 15 | ``gdb -ex 'target remote localhost:'``. 16 | 17 | To analyze errors in memory access run ``clickable desktop --valgrind``. 18 | 19 | .. _on-device-debugging: 20 | 21 | On Device 22 | --------- 23 | 24 | Two terminals are required to do debugging on the device, one to start the ``gdbserver`` 25 | and the other one to start ``gdb``. In the first terminal run ``clickable gdbserver`` 26 | and in the second one ``clickable gdb``. This method is limited to 27 | apps that are started via their own binary file. 28 | 29 | The ``clickable gdbserver`` command provides the server at ``localhost:3333``. In theory 30 | one could connect to that one from within any IDE. But to actually make it work, one needs 31 | to provide the corresponding libc6 debug symbols. Otherwise the App won't start due to a 32 | segfault. 33 | 34 | For detailed instructions on how to use gdb check out `gdb documentation `__. 35 | -------------------------------------------------------------------------------- /tests/unit/base_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from clickable.container import Container 4 | from clickable.exceptions import ClickableException 5 | from ..mocks import ConfigMock 6 | 7 | 8 | class UnitTest(TestCase): 9 | def setUpWithTmpBuildDir(self): 10 | config_json = {} 11 | config_json["build_dir"] = "/tmp/build" 12 | config_json["install_dir"] = "/tmp/build/install" 13 | self.setUpConfig(mock_config_json = config_json) 14 | 15 | def setUpConfig(self, 16 | expect_exception=False, 17 | mock_config_json={}, 18 | mock_config_env={}, 19 | *args, **kwargs): 20 | self.config = None 21 | try: 22 | self.config = ConfigMock( 23 | mock_config_json=mock_config_json, 24 | mock_config_env=mock_config_env, 25 | mock_install_files=True, 26 | *args, **kwargs 27 | ) 28 | self.config.container = Container(self.config) 29 | self.config.interactive = False 30 | if expect_exception: 31 | raise ClickableException("A ClickableException was expected, but was not raised") 32 | except ClickableException as e: 33 | if not expect_exception: 34 | raise e 35 | 36 | def setUp(self): 37 | self.config = None 38 | 39 | def tearDown(self): 40 | self.config = None 41 | -------------------------------------------------------------------------------- /clickable/system/queries/legacy_docker_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from clickable.system.query import Query 4 | from clickable.utils import run_subprocess_check_output 5 | 6 | 7 | class LegacyDockerVersion(Query): 8 | EARLIEST_NON_LEGACY_VERSION = '19.03' 9 | 10 | def is_met(self): 11 | return self.is_version_older_than( 12 | expected=self.parse_version(self.EARLIEST_NON_LEGACY_VERSION), 13 | actual=self.parse_version(self.get_docker_version_string()) 14 | ) 15 | 16 | def is_legacy_docker_version(self, docker_version_string): 17 | return self.is_version_older_than( 18 | expected=self.parse_version('19.03'), 19 | actual=self.parse_version(docker_version_string) 20 | ) 21 | 22 | def is_version_older_than(self, expected, actual): 23 | if actual['major'] == expected['major']: 24 | return actual['minor'] < expected['minor'] 25 | 26 | return actual['major'] < expected['major'] 27 | 28 | def parse_version(self, version_string): 29 | match = re.match(r'^(?P\d+)\.(?P\d+)', version_string) 30 | return { 31 | 'major': int(match.group('major')), 32 | 'minor': int(match.group('minor')) 33 | } 34 | 35 | def get_docker_version_string(self): 36 | return run_subprocess_check_output("docker version --format '{{.Client.Version}}'") 37 | 38 | def get_user_instructions(self): 39 | return None 40 | -------------------------------------------------------------------------------- /tests/unit/test_build.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.build import BuildCommand 5 | from ..mocks import empty_fn, false_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestBuildCommand(UnitTest): 10 | def setUp(self): 11 | super().setUp() 12 | self.setUpConfig() 13 | self.command = BuildCommand(self.config) 14 | self.click_cmd = 'click build {} --no-validate'.format(self.config.install_dir) 15 | 16 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 17 | def test_click_build(self, mock_run_command): 18 | self.command.click_build() 19 | 20 | mock_run_command.assert_called_once_with(self.click_cmd) 21 | 22 | @mock.patch('clickable.container.Container.run_command', side_effect=empty_fn) 23 | @mock.patch('os.path.exists', side_effect=false_fn) 24 | @mock.patch('os.makedirs', side_effect=empty_fn) 25 | @mock.patch('shutil.copyfile', side_effect=empty_fn) 26 | def test_click_build_click_output(self, mock_copyfile, mock_makedirs, mock_exists, mock_run_command): 27 | self.config.click_output = '/foo/bar' 28 | self.command.click_build() 29 | 30 | mock_run_command.assert_called_once_with(self.click_cmd) 31 | mock_exists.assert_called_with(ANY) 32 | mock_makedirs.assert_called_with(ANY) 33 | mock_copyfile.assert_called_with(ANY, ANY) 34 | 35 | 36 | # TODO implement more 37 | -------------------------------------------------------------------------------- /bash_completion: -------------------------------------------------------------------------------- 1 | # Completion for clickable 2 | # As read in https://debian-administration.org/article/317/An_introduction_to_bash_completion_part_2 3 | 4 | _clickable() 5 | { 6 | local cur prev opts base 7 | COMPREPLY=() 8 | cur="${COMP_WORDS[COMP_CWORD]}" 9 | prev="${COMP_WORDS[COMP_CWORD-1]}" 10 | 11 | # Basic commands to be completed. 12 | # ------------------------------- 13 | 14 | opts="--apikey --arch --config --container-mode --debug --dirty --docker-image --serial-number --ssh --verbose --version build build-libs clean clean-libs click-build create desktop devices install launch log logs no-lock publish review run screenshots shell test update writable-image" 15 | 16 | # Arguments to some of the basic commands. 17 | # ----------------------------------------- 18 | 19 | case "${prev}" in 20 | 21 | # desktop argument 22 | desktop) 23 | desktopOpts="--dark-mode --dirty --gdb --gdbserver --lang --nvidia --skip-build --verbose" 24 | COMPREPLY=( $(compgen -W "${desktopOpts}" -- ${cur}) ) 25 | return 0 26 | ;; 27 | 28 | # build argument 29 | build) 30 | desktopOpts="--output=" 31 | COMPREPLY=( $(compgen -W "${desktopOpts}" -- ${cur}) ) 32 | return 0 33 | ;; 34 | *) 35 | ;; 36 | esac 37 | 38 | COMPREPLY=($(compgen -W "${opts}" -- ${cur})) 39 | return 0 40 | } 41 | complete -F _clickable clickable 42 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: clickable 3 | Source: https://gitlab.com/clickable/clickable 4 | 5 | Files: * 6 | Copyright: 2016-2018 Brian Douglass 7 | License: GPL-3 8 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 3, as published by the Free Software Foundation 9 | . 10 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details 11 | . 12 | You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. 13 | 14 | Files: debian/* 15 | Copyright: 2017 JBBgameich 16 | License: GPL-3 17 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 3, as published by the Free Software Foundation 18 | . 19 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details 20 | . 21 | You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. 22 | -------------------------------------------------------------------------------- /clickable/builders/make.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shutil 3 | import sys 4 | import os 5 | 6 | from .base import Builder 7 | from clickable.logger import logger 8 | from clickable.config.project import ProjectConfig 9 | 10 | 11 | class MakeBuilder(Builder): 12 | def post_make(self): 13 | if self.config.postmake: 14 | subprocess.check_call(self.config.postmake, cwd=self.config.build_dir, shell=True) 15 | 16 | def post_make_install(self): 17 | pass 18 | 19 | def make(self): 20 | command = 'make' 21 | if self.config.make_args: 22 | command = '{} {}'.format(command, ' '.join(self.config.make_args)) 23 | 24 | if self.config.verbose: 25 | command = '{} {}'.format(command, 'VERBOSE=1') 26 | 27 | self.config.container.run_command(command) 28 | 29 | def make_install(self): 30 | if os.path.exists(self.config.install_dir) and os.path.isdir(self.config.install_dir): 31 | shutil.rmtree(self.config.install_dir) 32 | 33 | try: 34 | os.makedirs(self.config.install_dir) 35 | except FileExistsError: 36 | logger.warning('Failed to create temp dir, already exists') 37 | except Exception: 38 | logger.warning('Failed to create temp dir ({}): {}'.format(self.config.install_dir, str(sys.exc_info()[0]))) 39 | 40 | # The actual make command is implemented in the subclasses 41 | 42 | def build(self): 43 | self.make() 44 | self.post_make() 45 | self.make_install() 46 | self.post_make_install() 47 | -------------------------------------------------------------------------------- /clickable/commands/launch.py: -------------------------------------------------------------------------------- 1 | from .base import Command 2 | from clickable.logger import logger 3 | 4 | 5 | class LaunchCommand(Command): 6 | aliases = [] 7 | name = 'launch' 8 | help = 'Launches the app on a device' 9 | 10 | def kill(self): 11 | if self.config.is_desktop_mode(): 12 | logger.debug('Skipping kill, running in desktop mode') 13 | return 14 | elif self.config.container_mode: 15 | logger.debug('Skipping kill, running in container mode') 16 | return 17 | 18 | if self.config.kill: 19 | try: 20 | # Enclose first character in square brackets to prevent 21 | # spurious error when running `pkill -f` over `adb` 22 | kill = '[' + self.config.kill[:1] + ']' + self.config.kill[1:] 23 | self.device.run_command('pkill -f \\"{}\\"'.format(kill)) 24 | except Exception: 25 | pass # Nothing to do, the process probably wasn't running 26 | 27 | def preprocess(self, path_arg=None): 28 | if not path_arg: 29 | self.kill() 30 | 31 | def run(self, path_arg=None): 32 | cwd = '.' 33 | if path_arg: 34 | app = path_arg 35 | else: 36 | app = self.config.install_files.find_full_package_name() 37 | cwd = self.config.build_dir 38 | 39 | launch = 'ubuntu-app-launch {}'.format(app) 40 | if self.config.launch: 41 | launch = self.config.launch 42 | 43 | self.device.run_command('sleep 1s && {}'.format(launch), cwd=cwd) 44 | -------------------------------------------------------------------------------- /clickable/commands/clean_libs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | from .base import Command 6 | from clickable.logger import logger 7 | from clickable.exceptions import ClickableException 8 | 9 | 10 | class CleanLibsCommand(Command): 11 | aliases = [] 12 | name = 'clean-libs' 13 | help = 'Clean the library build directories' 14 | 15 | def run(self, path_arg=""): 16 | 17 | single_lib = path_arg 18 | found = False 19 | 20 | for lib in self.config.lib_configs: 21 | if not single_lib or single_lib == lib.name: 22 | logger.info("Cleaning {}".format(lib.name)) 23 | found = True 24 | 25 | if os.path.exists(lib.build_dir): 26 | try: 27 | shutil.rmtree(lib.build_dir) 28 | except Exception: 29 | cls, value, traceback = sys.exc_info() 30 | if cls == OSError and 'No such file or directory' in str(value): # TODO see if there is a proper way to do this 31 | pass # Nothing to do here, the directory didn't exist 32 | else: 33 | logger.warning('Failed to clean the build directory: {}: {}'.format(type, value)) 34 | else: 35 | logger.warning('Nothing to clean. Path does not exist: {}'.format(lib.build_dir)) 36 | 37 | if single_lib and not found: 38 | raise ClickableException('Cannot clean unknown library {}. You may add it to the clickable.json'.format(single_lib)) 39 | 40 | -------------------------------------------------------------------------------- /tests/unit/test_log.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.log import LogCommand 5 | from ..mocks import empty_fn, true_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestLogCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpConfig() 12 | self.command = LogCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 15 | def test_log(self, mock_run_command): 16 | self.command.run() 17 | 18 | mock_run_command.assert_called_once_with('cat ~/.cache/upstart/application-click-foo.bar_foo_1.2.3.log') 19 | 20 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 21 | def test_custom_log_file(self, mock_run_command): 22 | self.config.log = 'foo.log' 23 | self.command.run() 24 | 25 | mock_run_command.assert_called_once_with('cat foo.log') 26 | 27 | @mock.patch('clickable.config.project.ProjectConfig.is_desktop_mode', side_effect=true_fn) 28 | @mock.patch('clickable.commands.log.logger.debug', side_effect=empty_fn) 29 | def test_no_desktop_mode_log(self, mock_logger_debug, mock_desktop_mode): 30 | self.command.run() 31 | 32 | mock_logger_debug.assert_called_once_with(ANY) 33 | mock_desktop_mode.assert_called_once_with() 34 | 35 | @mock.patch('clickable.commands.log.logger.debug', side_effect=empty_fn) 36 | def test_no_container_mode_log(self, mock_logger_debug): 37 | self.config.container_mode = True 38 | self.command.run() 39 | 40 | mock_logger_debug.assert_called_once_with(ANY) 41 | -------------------------------------------------------------------------------- /tests/unit/test_logs.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.logs import LogsCommand 5 | from ..mocks import empty_fn, true_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestLogsCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpConfig() 12 | self.command = LogsCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 15 | def test_logs(self, mock_run_command): 16 | self.command.run() 17 | 18 | mock_run_command.assert_called_once_with('tail -f ~/.cache/upstart/application-click-foo.bar_foo_1.2.3.log') 19 | 20 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 21 | def test_custom_log_file(self, mock_run_command): 22 | self.config.log = 'foo.log' 23 | self.command.run() 24 | 25 | mock_run_command.assert_called_once_with('tail -f foo.log') 26 | 27 | @mock.patch('clickable.config.project.ProjectConfig.is_desktop_mode', side_effect=true_fn) 28 | @mock.patch('clickable.commands.logs.logger.debug', side_effect=empty_fn) 29 | def test_no_desktop_mode_logs(self, mock_logger_debug, mock_desktop_mode): 30 | self.command.run() 31 | 32 | mock_logger_debug.assert_called_once_with(ANY) 33 | mock_desktop_mode.assert_called_once_with() 34 | 35 | @mock.patch('clickable.commands.logs.logger.debug', side_effect=empty_fn) 36 | def test_no_container_mode_logs(self, mock_logger_debug): 37 | self.config.container_mode = True 38 | self.command.run() 39 | 40 | mock_logger_debug.assert_called_once_with(ANY) 41 | -------------------------------------------------------------------------------- /clickable/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | 5 | class Colors: 6 | DEBUG = '\033[94m' 7 | INFO = '\033[92m' 8 | WARNING = '\033[93m' 9 | ERROR = '\033[91m' 10 | CRITICAL = '\033[91m' 11 | CLEAR = '\033[0m' 12 | 13 | 14 | class ColorFormatter(logging.Formatter): 15 | def format(self, record): 16 | color = Colors.CLEAR 17 | if record.levelname == 'DEBUG': 18 | color = Colors.DEBUG 19 | elif record.levelname == 'INFO': 20 | color = Colors.INFO 21 | elif record.levelname == 'WARNING': 22 | color = Colors.WARNING 23 | elif record.levelname == 'ERROR': 24 | color = Colors.ERROR 25 | elif record.levelname == 'CRITICAL': 26 | color = Colors.CRITICAL 27 | 28 | record.msg = color + record.msg + Colors.CLEAR 29 | return super().format(record) 30 | 31 | 32 | # TODO log to a file 33 | 34 | logger = logging.getLogger('clickable') 35 | logger.setLevel(logging.DEBUG) 36 | 37 | console_handler = logging.StreamHandler() 38 | console_handler.setFormatter(ColorFormatter()) 39 | console_handler.setLevel(logging.INFO) 40 | logger.addHandler(console_handler) 41 | 42 | try: 43 | log_dir = os.path.expanduser('~/.clickable') 44 | log_file = os.path.join(log_dir, 'clickable.log') 45 | if not os.path.exists(log_dir): 46 | os.makedirs(log_dir) 47 | 48 | if os.path.exists(log_file): 49 | os.unlink(log_file) 50 | 51 | file_handler = logging.FileHandler(log_file) 52 | file_handler.setLevel(logging.DEBUG) 53 | logger.addHandler(file_handler) 54 | except Exception as e: 55 | logger.warning('Failed to setup logging to ~/.clickable/clickable.log', exc_info=e) 56 | 57 | -------------------------------------------------------------------------------- /clickable/commands/test_libs.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import Command 4 | from clickable.logger import logger 5 | from clickable.container import Container 6 | from clickable.exceptions import ClickableException 7 | 8 | 9 | class TestLibsCommand(Command): 10 | aliases = [] 11 | name = 'test-libs' 12 | help = 'Run tests on libraries' 13 | 14 | def run(self, path_arg=""): 15 | if not self.config.lib_configs: 16 | logger.warning('No libraries defined.') 17 | 18 | single_lib = path_arg 19 | found = False 20 | 21 | for lib in self.config.lib_configs: 22 | if not single_lib or single_lib == lib.name: 23 | logger.info("Running tests on {}".format(lib.name)) 24 | found = True 25 | 26 | self.run_test(lib) 27 | 28 | if single_lib and not found: 29 | raise ClickableException('Cannot test unknown library {}. You may add it to the clickable.json'.format(single_lib)) 30 | 31 | def run_test(self, lib): 32 | if not os.path.exists(lib.build_dir): 33 | logger.warning("Library {} has not yet been built for host architecture.".format(lib.name)) 34 | else: 35 | lib.container_mode = self.config.container_mode 36 | lib.docker_image = self.config.docker_image 37 | lib.build_arch = self.config.build_arch 38 | lib.container = Container(lib, lib.name) 39 | lib.container.setup() 40 | 41 | # This is a workaround for lib env vars being overwritten by 42 | # project env vars, especially affecting Container Mode. 43 | lib.set_env_vars() 44 | 45 | command = 'xvfb-startup {}'.format(lib.test) 46 | lib.container.run_command(command, use_build_dir=True) 47 | 48 | -------------------------------------------------------------------------------- /clickable/commands/create.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | 4 | from .base import Command 5 | from clickable.exceptions import ClickableException 6 | 7 | cookiecutter_available = True 8 | try: 9 | import cookiecutter.main 10 | except ImportError: 11 | cookiecutter_available = False 12 | 13 | 14 | COOKIECUTTER_URL = 'https://gitlab.com/clickable/ut-app-meta-template.git' 15 | 16 | 17 | # Map old template names to new template names 18 | TEMPLATE_MAP = { 19 | 'pure-qml-cmake': 'QML Only', 20 | 'cmake': 'C++ (Plugin)', 21 | 'python-cmake': 'Python', 22 | 'html': 'HTML', 23 | 'go': 'Go', 24 | 'main-cpp': 'C++ (Binary)', 25 | 'rust': 'Rust', 26 | } 27 | 28 | 29 | class CreateCommand(Command): 30 | aliases = ['init'] 31 | name = 'create' 32 | help = 'Generate a new app from a list of app template options' 33 | 34 | def run(self, path_arg=None): 35 | if not cookiecutter_available: 36 | raise ClickableException('Cookiecutter is not available on your computer, more information can be found here: https://cookiecutter.readthedocs.io/en/latest/installation.html#install-cookiecutter') 37 | 38 | config_file = os.path.expanduser('~/.clickable/cookiecutter_config.yaml') 39 | if not os.path.isfile(config_file): 40 | config_file = None 41 | 42 | extra_context = { 43 | 'Copyright Year': datetime.now().year 44 | } 45 | if path_arg: 46 | if path_arg in TEMPLATE_MAP: 47 | extra_context['Template'] = TEMPLATE_MAP[path_arg] 48 | else: 49 | extra_context['Template'] = path_arg 50 | 51 | no_input = not self.config.interactive 52 | 53 | try: 54 | cookiecutter.main.cookiecutter( 55 | COOKIECUTTER_URL, 56 | extra_context=extra_context, 57 | no_input=no_input, 58 | config_file=config_file, 59 | ) 60 | except cookiecutter.exceptions.FailedHookException as err: 61 | raise ClickableException('Failed to create app, see logs above') 62 | -------------------------------------------------------------------------------- /tests/mocks/config.py: -------------------------------------------------------------------------------- 1 | from clickable.config.project import ProjectConfig 2 | from clickable.config.constants import Constants 3 | from clickable.config.file_helpers import InstallFiles 4 | from clickable import __version__ 5 | from unittest.mock import Mock 6 | 7 | 8 | class InstallFilesMock(InstallFiles): 9 | def write_manifest(self, *args): 10 | pass 11 | 12 | def get_manifest(self): 13 | return { 14 | 'version': '1.2.3', 15 | 'name': 'foo.bar', 16 | 'architecture': '@CLICK_ARCH@', 17 | 'hooks': { 18 | 'foo': { 19 | 'desktop': '/fake/foo.desktop', 20 | }, 21 | }, 22 | } 23 | 24 | 25 | class ConfigMock(ProjectConfig): 26 | def __init__(self, 27 | mock_config_json=None, 28 | mock_config_env=None, 29 | mock_install_files=False, 30 | *args, **kwargs): 31 | self.mock_config_json = mock_config_json 32 | self.mock_config_env = mock_config_env 33 | self.mock_install_files = mock_install_files 34 | super().__init__(clickable_version=__version__, *args, **kwargs) 35 | 36 | def load_json_config(self, config_path): 37 | if self.mock_config_json is None: 38 | return super().load_json_config(config_path) 39 | else: 40 | config_json = self.mock_config_json 41 | return config_json 42 | 43 | def get_env_var(self, key): 44 | if self.mock_config_env is None: 45 | return super().get_env_var(key) 46 | else: 47 | return self.mock_config_env.get(key, None) 48 | 49 | def set_builder_interactive(self): 50 | if not self.config['builder'] and not self.needs_builder(): 51 | self.config["builder"] = Constants.PURE 52 | 53 | def setup_helpers(self): 54 | super().setup_helpers() 55 | if self.mock_install_files: 56 | self.install_files = InstallFilesMock( 57 | self.config['install_dir'], 58 | self.config['builder'], 59 | self.config['arch']) 60 | -------------------------------------------------------------------------------- /logo/clickable.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 23 | 25 | image/svg+xml 26 | 28 | 29 | 30 | 31 | 33 | 53 | 60 | 64 | 68 | 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import ast 5 | import re 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | readme = open('README.md').read() 13 | 14 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 15 | 16 | with open('clickable/__init__.py', 'rb') as f: 17 | version = str(ast.literal_eval(_version_re.search( 18 | f.read().decode('utf-8')).group(1))) 19 | 20 | requirements = [ 21 | 'cookiecutter', 22 | 'requests', 23 | 'jsonschema', 24 | ] 25 | 26 | setup( 27 | name='clickable-ut', 28 | version=version, 29 | description='Compile, build, and deploy Ubuntu Touch click packages all from the command line.', 30 | long_description=readme, 31 | long_description_content_type='text/markdown', 32 | author='Brian Douglass', 33 | url='https://clickable-ut.dev/', 34 | project_urls={ 35 | 'Documentation': 'https://clickable-ut.dev/en/latest/', 36 | 'Source': 'https://gitlab.com/clickable/clickable', 37 | 'Bug Tracker': 'https://gitlab.com/clickable/clickable/-/issues', 38 | }, 39 | packages=['clickable'], 40 | include_package_data=True, 41 | install_requires=requirements, 42 | license='GPL3', 43 | zip_safe=False, 44 | keywords='click ubuntu touch ubports', 45 | classifiers=[ 46 | 'Development Status :: 5 - Production/Stable', 47 | 'Environment :: Console', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 50 | 'Natural Language :: English', 51 | 'Topic :: Software Development :: Build Tools', 52 | 'Topic :: Software Development :: Code Generators', 53 | 'Programming Language :: Python :: 3', 54 | 'Programming Language :: Python :: 3.3', 55 | 'Programming Language :: Python :: 3.4', 56 | 'Programming Language :: Python :: 3.5', 57 | 'Programming Language :: Python :: 3.6', 58 | 'Programming Language :: Python :: 3.7', 59 | 'Programming Language :: Python :: 3.8', 60 | ], 61 | entry_points={ 62 | 'console_scripts': [ 63 | 'clickable = clickable:main', 64 | ], 65 | } 66 | ) 67 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from unittest.mock import ANY 3 | import multiprocessing 4 | 5 | from clickable.commands.clean import CleanCommand 6 | from clickable.container import Container 7 | from clickable.config.project import Constants 8 | from ..mocks import ConfigMock, empty_fn, true_fn 9 | 10 | 11 | class TestConfigCommand(TestCase): 12 | def setUp(self): 13 | self.config = ConfigMock(mock_config_env={}) 14 | self.config.arch = None 15 | self.config.make_jobs = None 16 | 17 | def test_set_conditional_defaults_default(self): 18 | self.config.container_mode = False 19 | self.config.set_conditional_defaults() 20 | self.assertEqual(self.config.arch, 'armhf') 21 | self.assertEqual(self.config.make_jobs, str(multiprocessing.cpu_count())) 22 | 23 | def test_set_conditional_defaults_make_args(self): 24 | self.config.make_args = 'test -j5 and more stuff' 25 | 26 | self.config.set_conditional_defaults() 27 | self.assertEqual(self.config.make_jobs, '5') 28 | 29 | def test_set_conditional_defaults_container_mode(self): 30 | self.config.host_arch = 'amd64' 31 | self.config.container_mode = True 32 | 33 | self.config.set_conditional_defaults() 34 | self.assertEqual(self.config.arch, 'amd64') 35 | 36 | def test_set_conditional_defaults_arch_agnostic(self): 37 | self.config.builder = Constants.PURE_QML_CMAKE 38 | 39 | self.config.set_conditional_defaults() 40 | self.assertEqual(self.config.arch, 'all') 41 | 42 | @mock.patch('clickable.config.project.ProjectConfig.is_desktop_mode', side_effect=true_fn) 43 | def test_set_conditional_defaults_arch_desktop(self, mock_desktop_mode): 44 | self.config.set_conditional_defaults() 45 | self.assertEqual(self.config.arch, 'amd64') 46 | mock_desktop_mode.assert_called_once_with() 47 | 48 | def test_set_conditional_defaults_restrict_arch(self): 49 | self.config.restrict_arch = 'arm64' 50 | 51 | self.config.set_conditional_defaults() 52 | self.assertEqual(self.config.arch, 'arm64') 53 | 54 | def test_set_conditional_defaults_restrict_arch_env(self): 55 | self.config.restrict_arch_env = 'arm64' 56 | 57 | self.config.set_conditional_defaults() 58 | self.assertEqual(self.config.arch, 'arm64') 59 | -------------------------------------------------------------------------------- /clickable/builders/pure.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | import os 4 | 5 | from .base import Builder 6 | from .make import MakeBuilder 7 | from .cmake import CMakeBuilder 8 | from .qmake import QMakeBuilder 9 | from clickable.logger import logger 10 | from clickable.config.project import ProjectConfig 11 | from clickable.config.constants import Constants 12 | from clickable.exceptions import ClickableException 13 | 14 | 15 | class PureQMLMakeBuilder(MakeBuilder): 16 | def post_make_install(self): 17 | super().post_make_install() 18 | 19 | manifest = self.config.install_files.get_manifest() 20 | manifest['architecture'] = 'all' 21 | self.config.install_files.write_manifest(manifest) 22 | 23 | 24 | class PureQMLQMakeBuilder(PureQMLMakeBuilder, QMakeBuilder): 25 | name = Constants.PURE_QML_QMAKE 26 | 27 | 28 | class PureQMLCMakeBuilder(PureQMLMakeBuilder, CMakeBuilder): 29 | name = Constants.PURE_QML_CMAKE 30 | 31 | 32 | class PureBuilder(Builder): 33 | name = Constants.PURE 34 | 35 | def _ignore(self, path, contents): 36 | ignored = [] 37 | for content in contents: 38 | cpath = os.path.abspath(os.path.join(path, content)) 39 | 40 | if ( 41 | cpath == os.path.abspath(self.config.install_dir) or 42 | cpath == os.path.abspath(self.config.build_dir) or 43 | content in self.config.ignore or 44 | content == 'clickable.json' 45 | ): 46 | ignored.append(content) 47 | 48 | return ignored 49 | 50 | def build(self): 51 | if os.path.isdir(self.config.install_dir): 52 | raise ClickableException('Build directory already exists. Please run "clickable clean" before building again!') 53 | shutil.copytree(self.config.cwd, self.config.install_dir, ignore=self._ignore) 54 | logger.info('Copied files to install directory for click building') 55 | 56 | 57 | class PythonBuilder(PureBuilder): 58 | # The only difference between this and the Pure builder is that this doesn't force the "all" arch 59 | name = Constants.PYTHON 60 | 61 | def build(self): 62 | logger.warn('The "python" builder is deprecated, please use "precompiled" instead') 63 | super().build() 64 | 65 | 66 | class PrecompiledBuilder(PureBuilder): 67 | # The only difference between this and the Pure builder is that this doesn't force the "all" arch 68 | name = Constants.PRECOMPILED 69 | -------------------------------------------------------------------------------- /clickable/commands/build_libs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from .base import Command 5 | from clickable.logger import logger 6 | from clickable.utils import get_builders, run_subprocess_check_call 7 | from clickable.container import Container 8 | from clickable.exceptions import ClickableException 9 | 10 | 11 | class LibBuildCommand(Command): 12 | aliases = [] 13 | name = 'build-libs' 14 | help = 'Compile the library dependencies' 15 | 16 | def run(self, path_arg=""): 17 | if not self.config.lib_configs: 18 | logger.warning('No libraries defined.') 19 | 20 | single_lib = path_arg 21 | found = False 22 | 23 | for lib in self.config.lib_configs: 24 | if not single_lib or single_lib == lib.name: 25 | logger.info("Building {}".format(lib.name)) 26 | found = True 27 | 28 | lib.container_mode = self.config.container_mode 29 | lib.docker_image = self.config.docker_image 30 | lib.build_arch = self.config.build_arch 31 | lib.container = Container(lib, lib.name) 32 | lib.container.setup() 33 | 34 | # This is a workaround for lib env vars being overwritten by 35 | # project env vars, especially affecting Container Mode. 36 | lib.set_env_vars() 37 | 38 | try: 39 | os.makedirs(lib.build_dir, exist_ok=True) 40 | except Exception: 41 | logger.warning('Failed to create the build directory: {}'.format(str(sys.exc_info()[0]))) 42 | 43 | try: 44 | os.makedirs(lib.build_home, exist_ok=True) 45 | except Exception: 46 | logger.warning('Failed to create the build home directory: {}'.format(str(sys.exc_info()[0]))) 47 | 48 | if lib.prebuild: 49 | run_subprocess_check_call(lib.prebuild, cwd=self.config.cwd, shell=True) 50 | 51 | self.build(lib) 52 | 53 | if lib.postbuild: 54 | run_subprocess_check_call(lib.postbuild, cwd=lib.build_dir, shell=True) 55 | 56 | if single_lib and not found: 57 | raise ClickableException('Cannot build unknown library {}, which is not in your clickable.json'.format(single_lib)) 58 | 59 | def build(self, lib): 60 | builder_classes = get_builders() 61 | builder = builder_classes[lib.builder](lib, None) 62 | builder.build() 63 | -------------------------------------------------------------------------------- /tests/unit/test_launch.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.launch import LaunchCommand 5 | from ..mocks import empty_fn, exception_fn, true_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestLaunchCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpWithTmpBuildDir() 12 | self.command = LaunchCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 15 | def test_kill(self, mock_run_command): 16 | self.config.kill = 'foo and bar' 17 | self.command.kill() 18 | 19 | mock_run_command.assert_called_once_with('pkill -f \\"[f]oo and bar\\"') 20 | 21 | @mock.patch('clickable.device.Device.run_command', side_effect=exception_fn) 22 | def test_kill_ignores_exceptions(self, mock_run_command): 23 | self.config.kill = 'foo and bar' 24 | self.command.kill() 25 | 26 | @mock.patch('clickable.config.project.ProjectConfig.is_desktop_mode', side_effect=true_fn) 27 | @mock.patch('clickable.commands.launch.logger.debug', side_effect=empty_fn) 28 | def test_kill_skips_desktop(self, mock_logger_debug, mock_desktop_mode): 29 | self.command.kill() 30 | 31 | mock_logger_debug.assert_called_once_with(ANY) 32 | mock_desktop_mode.assert_called_once_with() 33 | 34 | @mock.patch('clickable.commands.launch.logger.debug', side_effect=empty_fn) 35 | def test_kill_skips_container_mode(self, mock_logger_debug): 36 | self.config.container_mode = True 37 | self.command.kill() 38 | 39 | mock_logger_debug.assert_called_once_with(ANY) 40 | 41 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 42 | def test_launch(self, mock_run_command): 43 | self.command.run() 44 | 45 | mock_run_command.assert_called_once_with('sleep 1s && ubuntu-app-launch foo.bar_foo_1.2.3', cwd='/tmp/build') 46 | 47 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 48 | def test_launch_path_arg(self, mock_run_command): 49 | self.command.run('foo') 50 | 51 | mock_run_command.assert_called_once_with('sleep 1s && ubuntu-app-launch foo', cwd='.') 52 | 53 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 54 | def test_launch_custom(self, mock_run_command): 55 | self.config.launch = 'foo' 56 | self.command.run() 57 | 58 | mock_run_command.assert_called_once_with('sleep 1s && foo', cwd='/tmp/build') 59 | -------------------------------------------------------------------------------- /clickable/commands/install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .base import Command 5 | from clickable.utils import run_subprocess_check_call 6 | from clickable.logger import logger 7 | 8 | 9 | class InstallCommand(Command): 10 | aliases = [] 11 | name = 'install' 12 | help = 'Takes a built click package and installs it on a device' 13 | 14 | def try_find_installed_version(self, package_name): 15 | try: 16 | response = self.device.run_command('readlink /opt/click.ubuntu.com/{}/current'.format(package_name), get_output=True) 17 | return response.splitlines()[-1] 18 | except: 19 | return None 20 | 21 | def try_uninstall(self): 22 | package_name = self.config.install_files.find_package_name() 23 | version = self.try_find_installed_version(package_name) 24 | 25 | if version: 26 | self.device.run_command('pkcon remove \\"{};{};all;local:click\\"'.format(package_name, version)) 27 | 28 | def run(self, path_arg=None): 29 | if self.config.is_desktop_mode(): 30 | logger.debug('Skipping install, running in desktop mode') 31 | return 32 | elif self.config.container_mode: 33 | logger.debug('Skipping install, running in container mode') 34 | return 35 | 36 | cwd = '.' 37 | if path_arg: 38 | click = os.path.basename(path_arg) 39 | click_path = path_arg 40 | else: 41 | click = self.config.install_files.get_click_filename() 42 | click_path = os.path.join(self.config.build_dir, click) 43 | cwd = self.config.build_dir 44 | 45 | if self.config.ssh: 46 | command = 'scp {} phablet@{}:/home/phablet/'.format(click_path, self.config.ssh) 47 | run_subprocess_check_call(command, cwd=cwd, shell=True) 48 | 49 | else: 50 | self.device.check_any_attached() 51 | 52 | if self.config.device_serial_number: 53 | command = 'adb -s {} push {} /home/phablet/'.format(self.config.device_serial_number, click_path) 54 | else: 55 | self.device.check_multiple_attached() 56 | command = 'adb push {} /home/phablet/'.format(click_path) 57 | 58 | run_subprocess_check_call(command, cwd=cwd, shell=True) 59 | 60 | if path_arg: 61 | logger.info("Skipping uninstall step, because you specified a click package.") 62 | else: 63 | self.try_uninstall() 64 | 65 | self.device.run_command('pkcon install-local --allow-untrusted /home/phablet/{}'.format(click), cwd=cwd) 66 | self.device.run_command('rm /home/phablet/{}'.format(click), cwd=cwd) 67 | -------------------------------------------------------------------------------- /tests/integration/base_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import os 3 | import shutil 4 | 5 | from clickable.container import Container 6 | from clickable.commands.create import CreateCommand 7 | from clickable.exceptions import ClickableException 8 | from ..mocks import ClickableMock, ConfigMock 9 | 10 | class IntegrationTest(TestCase): 11 | def setUpConfig(self, 12 | expect_exception=False, 13 | mock_config_json={}, 14 | mock_config_env={}, 15 | *args, **kwargs): 16 | IntegrationTest.setUp(self) 17 | 18 | self.config = None 19 | try: 20 | self.config = ConfigMock( 21 | mock_config_json=mock_config_json, 22 | mock_config_env=mock_config_env, 23 | mock_install_files=True, 24 | *args, **kwargs 25 | ) 26 | self.config.container = Container(self.config) 27 | self.config.interactive = False 28 | if expect_exception: 29 | raise ClickableException("A ClickableException was expected, but was not raised") 30 | except ClickableException as e: 31 | if not expect_exception: 32 | raise e 33 | 34 | def setUp(self): 35 | self.clickable = None 36 | self.test_dir = os.path.abspath("tests/tmp") 37 | if os.path.exists(self.test_dir): 38 | shutil.rmtree(self.test_dir) 39 | 40 | def tearDown(self): 41 | self.clickable = None 42 | if os.path.exists(self.test_dir): 43 | shutil.rmtree(self.test_dir) 44 | 45 | def run_clickable(self, 46 | cli_args=[], 47 | expect_exception=False, 48 | config_json=None, 49 | config_env=None): 50 | """ 51 | Generic test run function 52 | 53 | :param list cli_args: command line to call clickable with 54 | :param bool expect_exception: asserts an ClickableException to be raised 55 | (True) or not to be raised (False) 56 | :param dict config_json: config to be used instead of loading the 57 | clickable.json 58 | :param dict config_env: env vars to be used instead of using system 59 | env vars 60 | """ 61 | self.clickable = ClickableMock(mock_config_json=config_json, 62 | mock_config_env=config_env) 63 | 64 | try: 65 | self.clickable.run_clickable(cli_args) 66 | if expect_exception: 67 | raise ClickableException("A ClickableException was expected, but was not raised") 68 | except ClickableException as e: 69 | if not expect_exception: 70 | raise e 71 | -------------------------------------------------------------------------------- /clickable/commands/publish.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | 4 | requests_available = True 5 | try: 6 | import requests 7 | except ImportError: 8 | requests_available = False 9 | 10 | from .base import Command 11 | from clickable.logger import logger 12 | from clickable.exceptions import ClickableException 13 | 14 | 15 | OPENSTORE_API = 'https://open-store.io' 16 | OPENSTORE_API_PATH = '/api/v3/manage/{}/revision' 17 | 18 | 19 | class PublishCommand(Command): 20 | aliases = [] 21 | name = 'publish' 22 | help = 'Publish your click app to the OpenStore' 23 | 24 | def run(self, path_arg=''): 25 | if not requests_available: 26 | raise ClickableException('Unable to publish app, python requests module is not installed') 27 | 28 | if not self.config.apikey: 29 | raise ClickableException('No api key specified, use OPENSTORE_API_KEY or --apikey') 30 | 31 | click = self.config.install_files.get_click_filename() 32 | click_path = os.path.join(self.config.build_dir, click) 33 | 34 | url = OPENSTORE_API 35 | if 'OPENSTORE_API' in os.environ and os.environ['OPENSTORE_API']: 36 | url = os.environ['OPENSTORE_API'] 37 | 38 | package_name = self.config.install_files.find_package_name() 39 | url = url + OPENSTORE_API_PATH.format(package_name) 40 | channel = 'xenial' 41 | files = {'file': open(click_path, 'rb')} 42 | data = { 43 | 'channel': channel, 44 | 'changelog': path_arg.encode('utf8', 'surrogateescape'), 45 | } 46 | params = {'apikey': self.config.apikey} 47 | 48 | logger.info('Uploading version {} of {} for {}/{} to the OpenStore'.format( 49 | self.config.install_files.find_version(), 50 | package_name, 51 | channel, 52 | self.config.arch, 53 | )) 54 | response = requests.post(url, files=files, data=data, params=params) 55 | if response.status_code == requests.codes.ok: 56 | logger.info('Upload successful') 57 | elif response.status_code == requests.codes.not_found: 58 | title = urllib.parse.quote(self.config.install_files.find_package_title()) 59 | raise ClickableException( 60 | 'App needs to be created in the OpenStore before you can publish it. Visit {}/submit?appId={}&name={}'.format( 61 | OPENSTORE_API, 62 | package_name, 63 | title, 64 | ) 65 | ) 66 | else: 67 | if response.text == 'Unauthorized': 68 | raise ClickableException('Failed to upload click: Unauthorized') 69 | else: 70 | raise ClickableException('Failed to upload click: {}'.format(response.json()['message'])) 71 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Clickable 2 | ========= 3 | 4 | Build and compile Ubuntu Touch apps easily from the command line. Deploy your 5 | apps to your Ubuntu Touch device for testing or test them on any desktop Linux 6 | distribution. Get logs for debugging and directly access a terminal on your device. 7 | 8 | Clickable is fully Open Source and can be found on `GitLab `__. 9 | Clickable is developed by `Brian Douglass `__ and 10 | `Jonatan Hatakeyama Zeidler `__ with a huge 11 | thank you to all the `contributors `__. 12 | 13 | Using Clickable 14 | --------------- 15 | 16 | .. toctree:: 17 | :maxdepth: 1 18 | :name: clickable 19 | 20 | install 21 | getting-started 22 | usage 23 | debugging 24 | commands 25 | clickable-json 26 | env-vars 27 | app-templates 28 | builders 29 | continuous-integration 30 | changelog 31 | 32 | Install Via Pip (Recommended) 33 | ----------------------------- 34 | 35 | * Install docker, adb, git, python3 and pip3 36 | (in Ubuntu: ``sudo apt install docker.io adb git python3 python3-pip``) 37 | * Run: ``pip3 install --user --upgrade clickable-ut`` 38 | * Add pip scripts to your PATH: ``echo 'export PATH="$PATH:~/.local/bin"' >> ~/.bashrc`` and open a new terminal for the setting to take effect 39 | * Alternatively, to install nightly builds: ``pip3 install --user git+https://gitlab.com/clickable/clickable.git@dev`` 40 | 41 | Install Via PPA (Ubuntu) 42 | ------------------------ 43 | 44 | * Add the `PPA `__ to your system: ``sudo add-apt-repository ppa:bhdouglass/clickable`` 45 | * Update your package list: ``sudo apt-get update`` 46 | * Install clickable: ``sudo apt-get install clickable`` 47 | 48 | 49 | Install Via AUR (Arch Linux) 50 | ---------------------------- 51 | 52 | * Using your favorite AUR helper, install the `clickable-git package `__ 53 | * Example: ``pacaur -S clickable-git`` 54 | 55 | Getting Started 56 | --------------- 57 | 58 | :ref:`Read the getting started guide to get started developing with clickable. ` 59 | 60 | Code Editor Integrations 61 | ------------------------ 62 | 63 | Use clickable with the `Atom Editor `__ by installing `atom-clickable-plugin `__. 64 | This is an fork of the original (now unmaintained) `atom-build-clickable `__ 65 | made by Stefano. 66 | 67 | Issues and Feature Requests 68 | --------------------------- 69 | 70 | If you run into any problems using clickable or have any feature requests you 71 | can find clickable on `GitLab `__. 72 | -------------------------------------------------------------------------------- /tests/integration/test_templates.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import os 3 | import shutil 4 | 5 | from clickable import Clickable 6 | from clickable.commands.create import CreateCommand 7 | from clickable.utils import run_subprocess_call 8 | from ..mocks import ConfigMock 9 | from .base_test import IntegrationTest 10 | 11 | 12 | class TestTemplates(IntegrationTest): 13 | def setUp(self): 14 | super().setUpConfig() 15 | self.original_path = os.getcwd() 16 | self.app_path = os.path.abspath(os.path.join(self.test_dir, 'appname')) 17 | 18 | os.makedirs(self.test_dir) 19 | os.chdir(self.test_dir) 20 | 21 | self.config_file = os.path.expanduser('~/.clickable/cookiecutter_config.yaml') 22 | self.tmp_config_file = '/tmp/cookiecutter_config.yaml' 23 | self.restore_config = False 24 | if os.path.exists(self.config_file): 25 | shutil.move(self.config_file, self.tmp_config_file) 26 | self.restore_config = True 27 | 28 | def tearDown(self): 29 | super().tearDown() 30 | os.chdir(self.original_path) 31 | 32 | if self.restore_config: 33 | shutil.move(self.tmp_config_file, self.config_file) 34 | 35 | def create_and_run(self, template, arch): 36 | command = CreateCommand(self.config) 37 | command.run(path_arg=template) 38 | os.chdir(self.app_path) 39 | 40 | if template == 'Go': 41 | run_subprocess_call('GOPATH=/tmp/gopath /usr/local/go/bin/go get', cwd=self.app_path, shell=True) 42 | 43 | self.run_clickable( 44 | cli_args=['clean', 'build', 'review', '--arch', arch], 45 | config_env={ 46 | 'GOPATH': '/tmp/gopath', 47 | }, 48 | ) 49 | 50 | def assertClickExists(self, arch): 51 | click = os.path.join(self.app_path, 'build/x86_64-linux-gnu/app/appname.yourname_1.0.0_amd64.click') 52 | if arch == 'all': 53 | click = os.path.join(self.app_path, 'build/all/app/appname.yourname_1.0.0_all.click') 54 | 55 | self.assertTrue(os.path.exists(click)) 56 | 57 | def test_qml_only(self): 58 | self.create_and_run('QML Only', 'all') 59 | self.assertClickExists('all') 60 | 61 | def test_cpp_plugin(self): 62 | self.create_and_run('C++', 'amd64') 63 | self.assertClickExists('amd64') 64 | 65 | def test_python(self): 66 | self.create_and_run('Python', 'all') 67 | self.assertClickExists('all') 68 | 69 | def test_html(self): 70 | self.create_and_run('HTML', 'all') 71 | self.assertClickExists('all') 72 | 73 | def test_go(self): 74 | self.create_and_run('Go', 'amd64') 75 | self.assertClickExists('amd64') 76 | 77 | def test_rust(self): 78 | self.create_and_run('Rust', 'amd64') 79 | self.assertClickExists('amd64') 80 | -------------------------------------------------------------------------------- /clickable/builders/cordova.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | import json 4 | import shutil 5 | import sys 6 | import os 7 | from distutils.dir_util import copy_tree 8 | 9 | from .cmake import CMakeBuilder 10 | from clickable.config.project import ProjectConfig 11 | from clickable.config.constants import Constants 12 | 13 | 14 | class CordovaBuilder(CMakeBuilder): 15 | name = Constants.CORDOVA 16 | 17 | # Lots of this code was based off of this: 18 | # https://github.com/apache/cordova-ubuntu/blob/28cd3c1b53c1558baed4c66cb2deba597f05b3c6/bin/templates/project/cordova/lib/build.js#L59-L131 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | self.platform_dir = os.path.join(self.config.cwd, 'platforms/ubuntu/') 23 | self.sdk = 'ubuntu-sdk-16.04' 24 | 25 | self.config.src_dir = os.path.join(self.platform_dir, 'build') 26 | 27 | if not os.path.isdir(self.platform_dir): 28 | command = self.config.container.run_command("cordova platform add ubuntu") 29 | 30 | def make_install(self): 31 | super().make_install() 32 | copies = { 33 | 'www': None, 34 | 'platform_www': 'www', 35 | 'config.xml': None, 36 | 'cordova.desktop': None, 37 | 'manifest.json': None, 38 | 'apparmor.json': None, 39 | } 40 | 41 | # If value is none, set to key 42 | copies = {key: key if value is None else value 43 | for key, value in copies.items()} 44 | 45 | # Is this overengineerd? 46 | for file_to_copy_source, file_to_copy_dest in copies.items(): 47 | full_source_path = os.path.join(self.platform_dir, 48 | file_to_copy_source) 49 | full_dest_path = os.path.join(self.config.install_dir, 50 | file_to_copy_dest) 51 | if os.path.isdir(full_source_path): 52 | # https://stackoverflow.com/a/31039095/6381767 53 | copy_tree(full_source_path, full_dest_path) 54 | else: 55 | shutil.copy(full_source_path, full_dest_path) 56 | 57 | # Modify default files with updated settings 58 | # taken straing from cordova build.js 59 | manifest = self.config.install_files.get_manifest() 60 | manifest['architecture'] = self.config.build_arch 61 | manifest['framework'] = self.sdk 62 | self.config.install_files.write_manifest(manifest) 63 | 64 | apparmor_file = os.path.join(self.config.install_dir, 'apparmor.json') 65 | with open(apparmor_file, 'r') as apparmor_reader: 66 | apparmor = json.load(apparmor_reader) 67 | apparmor['policy_version'] = 16.04 68 | 69 | if 'webview' not in apparmor['policy_groups']: 70 | apparmor['policy_groups'].append('webview') 71 | 72 | with open(apparmor_file, 'w') as apparmor_writer: 73 | json.dump(apparmor, apparmor_writer, indent=4) 74 | -------------------------------------------------------------------------------- /clickable/config/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class Constants(object): 4 | PURE_QML_QMAKE = 'pure-qml-qmake' 5 | QMAKE = 'qmake' 6 | PURE_QML_CMAKE = 'pure-qml-cmake' 7 | CMAKE = 'cmake' 8 | CUSTOM = 'custom' 9 | CORDOVA = 'cordova' 10 | PURE = 'pure' 11 | PYTHON = 'python' 12 | GO = 'go' 13 | RUST = 'rust' 14 | PRECOMPILED = 'precompiled' 15 | 16 | builders = [PURE_QML_QMAKE, QMAKE, PURE_QML_CMAKE, CMAKE, CUSTOM, CORDOVA, PURE, PYTHON, GO, RUST, PRECOMPILED] 17 | arch_agnostic_builders = [PURE_QML_QMAKE, PURE_QML_CMAKE, PURE] 18 | 19 | container_mapping = { 20 | "armhf": { 21 | ('16.04.4', 'armhf'): 'clickable/armhf-16.04-armhf:16.04.4-qt5.9', 22 | ('16.04.5', 'armhf'): 'clickable/armhf-16.04-armhf:16.04.5', 23 | }, 24 | "arm64": { 25 | ('16.04.4', 'arm64'): 'clickable/arm64-16.04-arm64:16.04.4-qt5.9', 26 | ('16.04.5', 'arm64'): 'clickable/arm64-16.04-arm64:16.04.5', 27 | }, 28 | "amd64": { 29 | ('16.04.4', 'armhf'): 'clickable/amd64-16.04-armhf:16.04.4-qt5.9', 30 | ('16.04.4', 'arm64'): 'clickable/amd64-16.04-arm64:16.04.4-qt5.9', 31 | ('16.04.4', 'amd64'): 'clickable/amd64-16.04-amd64:16.04.4-qt5.9', 32 | ('16.04.4', 'amd64-nvidia'): 'clickable/amd64-16.04-amd64-nvidia:16.04.4-qt5.9', 33 | ('16.04.4', 'amd64-ide'): 'clickable/amd64-16.04-amd64-ide:16.04.4-qt5.9', 34 | ('16.04.4', 'amd64-nvidia-ide'): 'clickable/amd64-16.04-amd64-nvidia-ide:16.04.4-qt5.9', 35 | ('16.04.5', 'armhf'): 'clickable/amd64-16.04-armhf:16.04.5', 36 | ('16.04.5', 'arm64'): 'clickable/amd64-16.04-arm64:16.04.5', 37 | ('16.04.5', 'amd64'): 'clickable/amd64-16.04-amd64:16.04.5', 38 | ('16.04.5', 'amd64-nvidia'): 'clickable/amd64-16.04-amd64-nvidia:16.04.5', 39 | ('16.04.5', 'amd64-ide'): 'clickable/amd64-16.04-amd64-ide:16.04.5', 40 | ('16.04.5', 'amd64-nvidia-ide'): 'clickable/amd64-16.04-amd64-nvidia-ide:16.04.5', 41 | } 42 | } 43 | 44 | framework_image_mapping = { 45 | "ubuntu-sdk-16.04": "16.04.4", 46 | "ubuntu-sdk-16.04.1": "16.04.4", 47 | "ubuntu-sdk-16.04.2": "16.04.4", 48 | "ubuntu-sdk-16.04.3": "16.04.4", 49 | "ubuntu-sdk-16.04.4": "16.04.4", 50 | "ubuntu-sdk-16.04.5": "16.04.5", 51 | } 52 | 53 | framework_fallback = "16.04.5" 54 | 55 | default_qt_framework_mapping = { 56 | '5.9': 'ubuntu-sdk-16.04.4', 57 | '5.12': 'ubuntu-sdk-16.04.5', 58 | } 59 | 60 | default_qt = '5.12' 61 | 62 | arch_triplet_mapping = { 63 | 'armhf': 'arm-linux-gnueabihf', 64 | 'arm64': 'aarch64-linux-gnu', 65 | 'amd64': 'x86_64-linux-gnu', 66 | 'all': 'all' 67 | } 68 | 69 | host_arch_mapping = { 70 | 'x86_64': 'amd64', 71 | 'aarch64': 'arm64', 72 | 'armv7l': 'armhf', 73 | } 74 | 75 | desktop_device_home = os.path.expanduser('~/.clickable/home') 76 | device_home = '/home/phablet' 77 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | .. _getting-started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | * Run ``clickable create`` to get started with a new app. 7 | * Choose from the list of :ref:`app templates `. 8 | * Provide all the needed information about your new app. 9 | * When the app has finished generating, enter the newly created directory containing your app. 10 | * Run ``clickable`` to compile your app and install it on your phone. 11 | 12 | Getting Logs 13 | ------------ 14 | 15 | To get logs from your app simply run `clickable logs`. This will give you output 16 | from C++ (``QDebug() << "message"``) or from QML (``console.log("message")``) 17 | in addition to any errors or warnings. 18 | 19 | Running on the Desktop 20 | ---------------------- 21 | 22 | Running the app on the desktop just requires you to run ``clickable desktop``. 23 | This is not as complete as running the app on your phone, but it can help 24 | speed up development. 25 | 26 | Accessing Your Device 27 | --------------------- 28 | 29 | If you need to access a terminal on your Ubuntu Touch device you can use 30 | ``clickable shell`` to open up a terminal to your device from your computer. 31 | This is a replacement for the old ``phablet-shell`` command. 32 | 33 | Ubuntu Touch SDK Api Docs 34 | ------------------------- 35 | 36 | For more information about the Ubuntu Touch QML or HTML SDK check out the 37 | `docs over at UBports `__. 38 | 39 | Run Automatic Review 40 | -------------------- 41 | 42 | Apps submitted to the OpenStore will undergo automatic review, to test your 43 | app before submitting it, run ``clickable review`` after you've compiled a click. 44 | This runs the ``click-review`` command against your click within the clickable 45 | container (no need to install it on your computer). 46 | 47 | .. _publishing: 48 | 49 | Handling Dependencies 50 | --------------------- 51 | For more information about compiling, using and deploying app dependencies, check out the 52 | `docs over at UBports `__. 53 | 54 | 55 | Publishing to the OpenStore 56 | --------------------------- 57 | 58 | If this is your first time publishing to the OpenStore, you need to 59 | `signup for an account `__. You can signup with 60 | your GitHub, GitLab, or Ubuntu account. 61 | 62 | If your app is new to the OpenStore you must first create your app by entering 63 | the name from your manifest.json and the app's title 64 | on the `OpenStore's submission page `__. 65 | 66 | If your app already exists you can use the ``clickable publish`` command to 67 | upload your compiled click file to the OpenStore. In order to publish to the 68 | OpenStore you need to grab your 69 | `api key from the OpenStore `__. After you have 70 | your api key you need to let Clickable know about it. You can either pass it 71 | as an argument every time: ``clickable publish --apikey XYZ`` Or you can set it 72 | as an environment variable: ``export OPENSTORE_API_KEY=XYZ`` (you can add this 73 | to your ``~/.bashrc`` to keep it set). 74 | -------------------------------------------------------------------------------- /clickable/commands/gdb.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import Command 4 | from clickable.exceptions import ClickableException 5 | 6 | from clickable.utils import ( 7 | run_subprocess_check_call, 8 | ) 9 | 10 | gdb_arch_target_mapping = { 11 | 'amd64': 'i386:x86-64', 12 | 'armhf': 'arm', 13 | 'arm64': 'aarch64', 14 | } 15 | 16 | class GdbCommand(Command): 17 | aliases = [] 18 | name = 'gdb' 19 | help = 'Connects to a remote gdb session on the device opened via the gdbserver command' 20 | 21 | def is_elf_file(self, path): 22 | try: 23 | run_subprocess_check_call("readelf {} -l > /dev/null 2>&1".format(path), shell=True) 24 | return True 25 | except: 26 | return False 27 | 28 | def choose_executable(self, dirs, filename): 29 | for d in dirs: 30 | path = os.path.join(d, filename) 31 | if os.path.isfile(path) and os.access(path, os.X_OK): 32 | return path 33 | return None 34 | 35 | def find_binary_path(self): 36 | desktop = self.config.install_files.get_desktop(self.config.install_dir) 37 | exec_list = desktop["Exec"].split() 38 | binary = None 39 | 40 | for arg in exec_list: 41 | if "=" not in arg: 42 | binary = arg 43 | break 44 | 45 | path = self.choose_executable( 46 | [self.config.install_dir, self.config.app_bin_dir], binary) 47 | if path: 48 | if self.is_elf_file(path): 49 | return path 50 | else: 51 | raise ClickableException('App executable "{}" is not an ELF file suitable for GDB debugging.'.format(path)) 52 | 53 | if binary == "qmlscene": 54 | raise ClickableException('Apps started via "qmlscene" are not supported by this debug method.') 55 | else: 56 | raise ClickableException('App binary "{}" found in desktop file could not be found in the app install directory. Please specify the path as "clickable gdb path/to/binary"'.format(binary)) 57 | 58 | def start_gdb(self, binary, port): 59 | libs = self.config.app_lib_dir 60 | arch = gdb_arch_target_mapping[self.config.arch] 61 | sysroot = "/usr/lib/debug/lib/{}".format(self.config.arch_triplet) 62 | src_dirs = [lib.src_dir for lib in self.config.lib_configs] 63 | src_dirs.append(self.config.root_dir) 64 | src_dirs = ':'.join(src_dirs) 65 | 66 | command = "gdb-multiarch {} -ex 'set directories {}' -ex 'set solib-search-path {}' -ex 'set architecture {}' -ex 'handle SIGILL nostop' -ex 'set sysroot {}' -ex 'target remote localhost:{}'".format( 67 | binary, src_dirs, libs, arch, sysroot, port) 68 | self.config.container.run_command(command, localhost=True, tty=True, 69 | use_build_dir=False) 70 | 71 | def run(self, path_arg=None): 72 | port = 3333 73 | 74 | if path_arg: 75 | binary_path = os.path.abspath(path_arg) 76 | if not path_arg: 77 | binary_path = self.find_binary_path() 78 | 79 | self.config.container.setup() 80 | self.start_gdb(binary_path, port) 81 | -------------------------------------------------------------------------------- /tests/unit/test_clean.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.clean import CleanCommand 5 | from ..mocks import empty_fn, true_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | def no_file_dir(path): 10 | if path == '/tmp/build': 11 | raise OSError('No such file or directory') 12 | 13 | 14 | def no_file_temp(path): 15 | if path == '/tmp/build/install': 16 | raise OSError('No such file or directory') 17 | 18 | 19 | def dir_exception(path): 20 | if path == '/tmp/build': 21 | raise Exception('bad') 22 | 23 | 24 | def temp_exception(path): 25 | if path == '/tmp/build/install': 26 | raise Exception('bad') 27 | 28 | 29 | class TestCleanCommand(UnitTest): 30 | def setUp(self): 31 | self.setUpWithTmpBuildDir() 32 | self.command = CleanCommand(self.config) 33 | 34 | @mock.patch('shutil.rmtree', side_effect=empty_fn) 35 | @mock.patch('os.path.exists', side_effect=true_fn) 36 | @mock.patch('clickable.commands.clean.logger.warning', side_effect=empty_fn) 37 | def test_clean(self, mock_logger_warning, mock_exists, mock_rmtree): 38 | self.command.run() 39 | 40 | mock_exists.assert_called_with(ANY) 41 | mock_rmtree.assert_called_with(ANY) 42 | mock_logger_warning.assert_not_called() 43 | 44 | @mock.patch('shutil.rmtree', side_effect=no_file_dir) 45 | @mock.patch('os.path.exists', side_effect=true_fn) 46 | @mock.patch('clickable.commands.clean.logger.warning', side_effect=empty_fn) 47 | def test_no_file_dir(self, mock_logger_warning, mock_exists, mock_rmtree): 48 | self.command.run() 49 | 50 | mock_exists.assert_called_with(ANY) 51 | mock_rmtree.assert_called_with(ANY) 52 | mock_logger_warning.assert_not_called() 53 | 54 | @mock.patch('shutil.rmtree', side_effect=no_file_temp) 55 | @mock.patch('os.path.exists', side_effect=true_fn) 56 | @mock.patch('clickable.commands.clean.logger.warning', side_effect=empty_fn) 57 | def test_no_file_temp(self, mock_logger_warning, mock_exists, mock_rmtree): 58 | self.command.run() 59 | 60 | mock_exists.assert_called_with(ANY) 61 | mock_rmtree.assert_called_with(ANY) 62 | mock_logger_warning.assert_not_called() 63 | 64 | @mock.patch('shutil.rmtree', side_effect=dir_exception) 65 | @mock.patch('os.path.exists', side_effect=true_fn) 66 | @mock.patch('clickable.commands.clean.logger.warning', side_effect=empty_fn) 67 | def test_dir_exception(self, mock_logger_warning, mock_exists, mock_rmtree): 68 | self.command.run() 69 | 70 | mock_exists.assert_called_with(ANY) 71 | mock_rmtree.assert_called_with(ANY) 72 | mock_logger_warning.assert_called_with(ANY) 73 | 74 | @mock.patch('shutil.rmtree', side_effect=temp_exception) 75 | @mock.patch('os.path.exists', side_effect=true_fn) 76 | @mock.patch('clickable.commands.clean.logger.warning', side_effect=empty_fn) 77 | def test_temp_exception(self, mock_logger_warning, mock_exists, mock_rmtree): 78 | self.command.run() 79 | 80 | mock_exists.assert_called_with(ANY) 81 | mock_rmtree.assert_called_with(ANY) 82 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - publish 4 | 5 | integration-test: 6 | stage: test 7 | image: clickable/testing 8 | script: 9 | - nosetests tests/integration 10 | 11 | unit-test: 12 | stage: test 13 | image: clickable/testing 14 | script: 15 | - nosetests tests/unit 16 | 17 | build-deb: 18 | stage: publish 19 | image: clickable/build-deb:bionic 20 | rules: 21 | - if: $CI_COMMIT_TAG == null 22 | artifacts: 23 | expire_in: 1 week 24 | paths: 25 | - ../*.deb 26 | script: 27 | - dpkg-buildpackage 28 | 29 | publish-pypi: 30 | stage: publish 31 | image: clickable/clickable-ci 32 | rules: 33 | - if: $CI_COMMIT_TAG 34 | script: 35 | - python3 setup.py sdist bdist_wheel 36 | - python3 -m twine upload dist/* 37 | 38 | publish-trusty: 39 | stage: publish 40 | image: clickable/build-deb:bionic 41 | rules: 42 | - if: $CI_COMMIT_TAG 43 | script: 44 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 45 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 46 | - sed -i "s/) unstable/~trusty) trusty/g" debian/changelog 47 | - debuild -S 48 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 49 | 50 | publish-xenial: 51 | stage: publish 52 | image: clickable/build-deb:bionic 53 | rules: 54 | - if: $CI_COMMIT_TAG 55 | script: 56 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 57 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 58 | - sed -i "s/) unstable/~xenial) xenial/g" debian/changelog 59 | - debuild -S 60 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 61 | 62 | publish-bionic: 63 | stage: publish 64 | image: clickable/build-deb:bionic 65 | rules: 66 | - if: $CI_COMMIT_TAG 67 | script: 68 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 69 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 70 | - sed -i "s/) unstable/~bionic) bionic/g" debian/changelog 71 | - debuild -S 72 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 73 | 74 | publish-focal: 75 | stage: publish 76 | image: clickable/build-deb:bionic 77 | rules: 78 | - if: $CI_COMMIT_TAG 79 | script: 80 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 81 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 82 | - sed -i "s/) unstable/~focal) focal/g" debian/changelog 83 | - debuild -S 84 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 85 | 86 | publish-groovy: 87 | stage: publish 88 | image: clickable/build-deb:bionic 89 | rules: 90 | - if: $CI_COMMIT_TAG 91 | script: 92 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 93 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 94 | - sed -i "s/) unstable/~groovy) groovy/g" debian/changelog 95 | - debuild -S 96 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 97 | 98 | publish-hirsute: 99 | stage: publish 100 | image: clickable/build-deb:bionic 101 | rules: 102 | - if: $CI_COMMIT_TAG 103 | script: 104 | - echo $GPG_PUBLIC > pub.gpg && gpg --import pub.gpg && rm pub.gpg 105 | - echo $GPG_PRIVATE > pri.gpg && gpg --allow-secret-key-import --import pri.gpg && rm pri.gpg 106 | - sed -i "s/) unstable/~hirsute) hirsute/g" debian/changelog 107 | - debuild -S 108 | - dput ppa:bhdouglass/clickable ../clickable_*_source.changes 109 | -------------------------------------------------------------------------------- /clickable/commands/docker/docker_config.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | class DockerConfig(object): 3 | docker_executable = 'docker' 4 | volumes = {} 5 | environment = {} 6 | extra_options = {} 7 | extra_flags = [] 8 | 9 | execute = None 10 | pseudo_tty = False 11 | 12 | working_directory = '' 13 | docker_image = '' 14 | 15 | uid = '' 16 | 17 | use_nvidia = False 18 | 19 | def add_volume_mappings(self, volume_mapping_dict): 20 | self.volumes.update(volume_mapping_dict) 21 | 22 | def add_environment_variables(self, environment_variables_dict): 23 | self.environment.update(environment_variables_dict) 24 | 25 | def add_extra_flags(self, extra_flags_list): 26 | self.extra_flags += extra_flags_list 27 | 28 | def add_extra_options(self, extra_options_dict): 29 | self.extra_options.update(extra_options_dict) 30 | 31 | def render_command(self): 32 | volume_mapping_string = self.render_volume_mapping_string() 33 | environment_string = self.render_environment_string() 34 | extra_options_string = self.render_extra_options_string() 35 | extra_flags_string = self.render_extra_flags_string() 36 | 37 | return self.render_command_string( 38 | volume_mapping_string, 39 | environment_string, 40 | extra_options_string, 41 | extra_flags_string, 42 | ) 43 | 44 | def render_volume_mapping_string(self): 45 | # e.g. "-v /host/path:/container/path:Z -v /other/path:/other/path:Z 46 | return self.render_dictionary_as_mapping(self.volumes, '-v ', ':Z') 47 | 48 | def render_dictionary_as_mapping(self, dict, prefix='', suffix=''): 49 | return ' '.join(self.join_dictionary_items(dict, ':', prefix, suffix)) 50 | 51 | def render_environment_string(self): 52 | # e.g. "-e VAR=value -e X=y 53 | return self.render_dictionary_as_variables(self.environment, '-e ') 54 | 55 | def render_dictionary_as_variables(self, dict, prefix=''): 56 | return ' '.join(self.join_dictionary_items(dict, '=', prefix)) 57 | 58 | def join_dictionary_items(self, dict, glue=':', prefix='', suffix=''): 59 | return map(lambda key_value_list: '{prefix}{name}{glue}{value}{suffix}'.format( 60 | prefix=prefix, 61 | name=key_value_list[0], 62 | glue=glue, 63 | value=shlex.quote(key_value_list[1]), 64 | suffix=suffix 65 | ), dict.items()) 66 | 67 | def render_extra_options_string(self): 68 | # {'--my-option': 'value', '--my-other-option': 'test'} 69 | # --my-option=value --my-other-option=test 70 | return self.render_dictionary_as_variables(self.extra_options) 71 | 72 | def render_extra_flags_string(self): 73 | # ['--my-flag', '--my-other-flag'] 74 | # --my-flag --my-other-flag 75 | return ' '.join(self.extra_flags) 76 | 77 | def render_command_string(self, volumes_string, environment_string, 78 | extra_options_string, extra_flags_string): 79 | return ( 80 | '{docker} run --privileged --net=host {volumes} {env} {extra_options} ' 81 | '{extra_flags} -w {working_dir} --user={uid} ' 82 | '--rm {tty} -i {docker_image} bash -c "{executable}"' 83 | ).format( 84 | docker=self.docker_executable, 85 | volumes=volumes_string, 86 | env=environment_string, 87 | extra_options=extra_options_string, 88 | extra_flags=extra_flags_string, 89 | working_dir=self.working_directory, 90 | uid=self.uid, 91 | docker_image=self.docker_image, 92 | executable=self.execute, 93 | tty="--tty" if self.pseudo_tty else "" 94 | ) 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Clickable](https://clickable-ut.dev/en/latest/) 2 | 3 | Compile, build, and deploy Ubuntu Touch click packages all from the command line. 4 | 5 | ## Install 6 | 7 | ### Via Pip (Recommended) 8 | 9 | * Install docker, adb, git, python3 and pip3 10 | (in Ubuntu: `sudo apt install docker.io adb git python3 python3-pip`) 11 | * Run: `pip3 install --user --upgrade clickable-ut` 12 | * Add pip scripts to your PATH: `echo 'export PATH="$PATH:~/.local/bin"' >> ~/.bashrc` and open a new terminal for the setting to take effect 13 | * Alternatively, to install nightly builds: `pip3 install --user git+https://gitlab.com/clickable/clickable.git@dev` 14 | 15 | ### Via PPA (Ubuntu) 16 | 17 | * Add the PPA to your system: `sudo add-apt-repository ppa:bhdouglass/clickable` 18 | * Update your package list: `sudo apt-get update` 19 | * Install clickable: `sudo apt-get install clickable` 20 | 21 | ### Via AUR (Arch Linux) 22 | 23 | * Using your favorite AUR helper, install the [clickable package](https://aur.archlinux.org/packages/clickable/) 24 | * Example: `pacaur -S clickable 25 | 26 | ## After install 27 | 28 | * Launch clickable and let it setup docker (it could ask for the sudo password): `clickable` 29 | * Log out or restart to apply changes 30 | 31 | ## Docs 32 | 33 | - [Getting Started](https://clickable-ut.dev/en/latest/getting-started.html) 34 | - [Usage](https://clickable-ut.dev/en/latest/usage.html) 35 | - [Commands](https://clickable-ut.dev/en/latest/commands.html) 36 | - [clickable.json Format](https://clickable-ut.dev/en/latest/clickable-json.html) 37 | - [App Templates](https://clickable-ut.dev/en/latest/app-templates.html) 38 | - [Builders](https://clickable-ut.dev/en/latest/builders.html) 39 | 40 | ## Code Editor Integrations 41 | 42 | Use clickable with the [Atom Editor](https://atom.io) by installing [atom-clickable-plugin](https://atom.io/packages/atom-clickable-plugin). 43 | This is an fork of the original (now unmaintained) [atom-build-clickable](https://atom.io/packages/atom-build-clickable) 44 | made by Stefano. 45 | 46 | ## Development 47 | 48 | ### Run clickable 49 | 50 | Best add your clickable folder to `PATH`, so you don't need to run the clickable commands from the repo root. 51 | This can be done by adding `export PATH="$PATH:$HOME/clickable"` to your `.bashrc`. 52 | Replace `$HOME/clickable` with your path. 53 | 54 | To test clickable, run `clickable-dev`. Add the `--verbose` option for additional output. 55 | 56 | To enable configuration validation either install **jsonschema** via pip 57 | (`pip3 install jsonschema`) or apt (`apt install python3-jsonschema`). If you 58 | got clickable regularly installed, you already have jsonschema, too. 59 | 60 | ### Run the tests 61 | 62 | Install nose and the coverage modules: `pip3 install nose coverage`. 63 | 64 | Run nose to complete the tests: `nosetests`. 65 | 66 | ### Related Repositories 67 | 68 | * [Clickable docker images and app templates](https://gitlab.com/clickable) 69 | 70 | ## Donate 71 | 72 | If you like Clickable, consider giving a small donation over at my 73 | [Liberapay page](https://liberapay.com/bhdouglass). 74 | 75 | ## License 76 | 77 | Copyright (C) 2020 [Brian Douglass](http://bhdouglass.com/) 78 | 79 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 3, as published 80 | by the Free Software Foundation. 81 | 82 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 83 | 84 | You should have received a copy of the GNU General Public License along with this program. If not, see . 85 | -------------------------------------------------------------------------------- /docs/env-vars.rst: -------------------------------------------------------------------------------- 1 | .. _env-vars: 2 | 3 | Environment Variables 4 | ===================== 5 | 6 | Environment variables will override values in the clickable.json and can be 7 | overridden by command line arguments. 8 | 9 | In contrast to the environment variables described here that configure 10 | Clickable, there are :ref:`environment variables ` set by 11 | Clickable to be used during build. 12 | 13 | ``CLICKABLE_ARCH`` 14 | ------------------ 15 | 16 | Restricts build commands (``build``, ``build-libs``, ``desktop``) to the specified architecture. Architecture agnostic builds (``all``) are not affected. Useful in container mode. 17 | 18 | ``CLICKABLE_QT_VERSION`` 19 | ------------------------ 20 | 21 | Overrides the clickable.json's :ref:`qt_version `. 22 | 23 | ``CLICKABLE_FRAMEWORK`` 24 | ----------------------- 25 | 26 | Overrides the clickable.json's :ref:`builder `. 27 | 28 | ``CLICKABLE_BUILDER`` 29 | --------------------- 30 | 31 | Overrides the clickable.json's :ref:`builder `. 32 | 33 | ``CLICKABLE_BUILD_DIR`` 34 | ----------------------- 35 | 36 | Overrides the clickable.json's :ref:`dir `. 37 | 38 | ``CLICKABLE_DEFAULT`` 39 | --------------------- 40 | 41 | Overrides the clickable.json's :ref:`default `. 42 | 43 | ``CLICKABLE_MAKE_JOBS`` 44 | ----------------------- 45 | 46 | Overrides the clickable.json's :ref:`make_jobs `. 47 | 48 | ``GOPATH`` 49 | ---------- 50 | 51 | Overrides the clickable.json's :ref:`gopath `. 52 | 53 | ``CARGO_HOME`` 54 | -------------- 55 | 56 | Overrides the clickable.json's :ref:`cargo_home `. 57 | 58 | ``CLICKABLE_DOCKER_IMAGE`` 59 | -------------------------- 60 | 61 | Overrides the clickable.json's :ref:`docker_image `. 62 | 63 | ``CLICKABLE_BUILD_ARGS`` 64 | ------------------------ 65 | 66 | Overrides the clickable.json's :ref:`build_args `. 67 | 68 | ``CLICKABLE_MAKE_ARGS`` 69 | ------------------------ 70 | 71 | Overrides the clickable.json's :ref:`make_args `. 72 | 73 | ``OPENSTORE_API_KEY`` 74 | --------------------- 75 | 76 | Your api key for :ref:`publishing to the OpenStore `. 77 | 78 | ``CLICKABLE_CONTAINER_MODE`` 79 | ---------------------------- 80 | 81 | Same as :ref:`--container-mode `. 82 | 83 | ``CLICKABLE_SERIAL_NUMBER`` 84 | --------------------------- 85 | 86 | Same as :ref:`--serial-number `. 87 | 88 | ``CLICKABLE_SSH`` 89 | ----------------- 90 | 91 | Same as :ref:`--ssh `. 92 | 93 | ``CLICKABLE_OUTPUT`` 94 | -------------------- 95 | 96 | Override the output directory for the resulting click file 97 | 98 | ``CLICKABLE_NVIDIA`` 99 | -------------------- 100 | 101 | Same as :ref:`--nvidia `. 102 | 103 | ``CLICKABLE_NO_NVIDIA`` 104 | ----------------------- 105 | 106 | Same as :ref:`--no-nvidia `. 107 | 108 | ``CLICKABLE_DIRTY`` 109 | ------------------- 110 | 111 | Overrides the clickable.json's :ref:`dirty `. 112 | 113 | ``CLICKABLE_NON_INTERACTIVE`` 114 | ----------------------------- 115 | 116 | Same as ``--non-interactive`` 117 | 118 | ``CLICKABLE_DEBUG_BUILD`` 119 | ------------------------- 120 | 121 | Same as ``--debug`` 122 | 123 | ``CLICKABLE_TEST`` 124 | ------------------ 125 | 126 | Overrides the clickable.json's :ref:`test `. 127 | 128 | ``CLICKABLE_DARK_MODE`` 129 | ----------------------- 130 | 131 | Same as ``--dark-mode`` 132 | 133 | ``CLICKABLE_ENV_`` 134 | -------------------------- 135 | 136 | Adds custom env vars to the build container. E.g. set 137 | ``CLICKABLE_ENV_BUILD_TESTS=ON`` to have ``BUILD_TESTS=ON`` set in the build 138 | container. 139 | 140 | Overrides env vars in :ref:`test `. 141 | -------------------------------------------------------------------------------- /clickable/device.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .utils import ( 4 | run_subprocess_check_output, 5 | run_subprocess_check_call, 6 | ) 7 | from .exceptions import ClickableException 8 | from .logger import logger 9 | 10 | 11 | class Device(object): 12 | def __init__(self, config): 13 | self.config = config 14 | 15 | def detect_attached(self): 16 | output = run_subprocess_check_output('adb devices -l').strip() 17 | devices = [] 18 | for line in output.split('\n'): 19 | if 'device' in line and 'devices' not in line: 20 | device = line.split(' ')[0] 21 | for part in line.split(' '): 22 | if part.startswith('model:'): 23 | device = '{} - {}'.format(device, part.replace('model:', '').replace('_', ' ').strip()) 24 | 25 | devices.append(device) 26 | 27 | return devices 28 | 29 | def check_any_attached(self): 30 | devices = self.detect_attached() 31 | if len(devices) == 0: 32 | raise ClickableException('Cannot access device.\nADB: No devices attached\nSSH: no IP address specified (--ssh)') 33 | 34 | def check_multiple_attached(self): 35 | devices = self.detect_attached() 36 | if len(devices) > 1 and not self.config.device_serial_number: 37 | raise ClickableException('Multiple devices detected via adb') 38 | 39 | def get_adb_args(self): 40 | self.check_any_attached() 41 | if self.config.device_serial_number: 42 | return '-s {}'.format(self.config.device_serial_number) 43 | else: 44 | self.check_multiple_attached() 45 | return '' 46 | 47 | def forward_port_adb(self, port, adb_args): 48 | command = 'adb {0} forward tcp:{1} tcp:{1}'.format(adb_args, port) 49 | run_subprocess_check_call(command) 50 | 51 | def push_file(self, src, dst): 52 | if self.config.ssh: 53 | dir_path = os.path.dirname(dst) 54 | self.run_command('mkdir -p {}'.format(dir_path)) 55 | command = 'scp {} phablet@{}:{}'.format(src, self.config.ssh, dst) 56 | else: 57 | adb_args = self.get_adb_args() 58 | command = 'adb {} push {} {}'.format(adb_args, src, dst) 59 | 60 | run_subprocess_check_call(command, shell=True) 61 | 62 | def get_ssh_command(self, command, forward_port=None): 63 | ssh_args = "" 64 | 65 | if forward_port: 66 | ssh_args = "{0} -L {1}:localhost:{1}".format(ssh_args, forward_port) 67 | 68 | if isinstance(command, list): 69 | command = " && ".join(command) 70 | 71 | return 'echo "{}" | ssh {} phablet@{}'.format( 72 | command, ssh_args, self.config.ssh) 73 | 74 | def get_adb_command(self, command, forward_port=None): 75 | adb_args = self.get_adb_args() 76 | 77 | if forward_port: 78 | self.forward_port_adb(forward_port, adb_args) 79 | 80 | if isinstance(command, list): 81 | command = ";".join(command) 82 | 83 | return 'adb {} shell "{}"'.format(adb_args, command) 84 | 85 | def run_command(self, command, cwd=None, get_output=False, forward_port=None): 86 | if self.config.container_mode: 87 | logger.debug('Skipping device command, running in container mode') 88 | return 89 | 90 | if not cwd: 91 | cwd = self.config.build_dir 92 | 93 | wrapped_command = '' 94 | if self.config.ssh: 95 | logger.debug("Accessing {} via SSH".format(self.config.ssh)) 96 | wrapped_command = self.get_ssh_command(command, forward_port) 97 | else: 98 | logger.debug("Accessing device via ADB") 99 | wrapped_command = self.get_adb_command(command, forward_port) 100 | 101 | if get_output: 102 | return run_subprocess_check_output(wrapped_command, cwd=cwd, shell=True) 103 | else: 104 | run_subprocess_check_call(wrapped_command, cwd=cwd, shell=True) 105 | -------------------------------------------------------------------------------- /tests/unit/test_install.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import ANY 3 | 4 | from clickable.commands.install import InstallCommand 5 | from ..mocks import empty_fn, true_fn 6 | from .base_test import UnitTest 7 | 8 | 9 | class TestInstallCommand(UnitTest): 10 | def setUp(self): 11 | self.setUpWithTmpBuildDir() 12 | self.command = InstallCommand(self.config) 13 | 14 | @mock.patch('clickable.device.Device.check_any_attached', side_effect=empty_fn) 15 | @mock.patch('clickable.device.Device.check_multiple_attached', side_effect=empty_fn) 16 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 17 | @mock.patch('clickable.commands.install.run_subprocess_check_call', side_effect=empty_fn) 18 | def test_install_adb(self, mock_run_subprocess_check_call, mock_run_command, mock_check_multiple_attached, mock_check_any_attached): 19 | self.command.run() 20 | 21 | mock_check_any_attached.assert_called_once_with() 22 | mock_check_multiple_attached.assert_called_once_with() 23 | mock_run_subprocess_check_call.assert_called_once_with('adb push /tmp/build/foo.bar_1.2.3_armhf.click /home/phablet/', cwd='/tmp/build', shell=True) 24 | mock_run_command.assert_called_with(ANY, cwd='/tmp/build') 25 | 26 | @mock.patch('clickable.device.Device.check_any_attached', side_effect=empty_fn) 27 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 28 | @mock.patch('clickable.commands.install.run_subprocess_check_call', side_effect=empty_fn) 29 | def test_install_serial_number(self, mock_run_subprocess_check_call, mock_run_command, mock_check_any_attached): 30 | self.config.device_serial_number = 'foo' 31 | self.command.run() 32 | 33 | mock_check_any_attached.assert_called_once_with() 34 | mock_run_subprocess_check_call.assert_called_once_with('adb -s foo push /tmp/build/foo.bar_1.2.3_armhf.click /home/phablet/', cwd='/tmp/build', shell=True) 35 | mock_run_command.assert_called_with(ANY, cwd='/tmp/build') 36 | 37 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 38 | @mock.patch('clickable.commands.install.run_subprocess_check_call', side_effect=empty_fn) 39 | def test_install_scp(self, mock_run_subprocess_check_call, mock_run_command): 40 | self.config.ssh = 'foo' 41 | self.command.run() 42 | 43 | mock_run_subprocess_check_call.assert_called_once_with('scp /tmp/build/foo.bar_1.2.3_armhf.click phablet@foo:/home/phablet/', cwd='/tmp/build', shell=True) 44 | mock_run_command.assert_called_with(ANY, cwd='/tmp/build') 45 | 46 | @mock.patch('clickable.device.Device.check_any_attached', side_effect=empty_fn) 47 | @mock.patch('clickable.device.Device.check_multiple_attached', side_effect=empty_fn) 48 | @mock.patch('clickable.device.Device.run_command', side_effect=empty_fn) 49 | @mock.patch('clickable.commands.install.run_subprocess_check_call', side_effect=empty_fn) 50 | def test_install_adb_with_path(self, mock_run_subprocess_check_call, mock_run_command, mock_check_multiple_attached, mock_check_any_attached): 51 | self.command.run('/foo/bar.click') 52 | 53 | mock_check_any_attached.assert_called_once_with() 54 | mock_check_multiple_attached.assert_called_once_with() 55 | mock_run_subprocess_check_call.assert_called_once_with('adb push /foo/bar.click /home/phablet/', cwd='.', shell=True) 56 | mock_run_command.assert_called_with(ANY, cwd='.') 57 | 58 | @mock.patch('clickable.config.project.ProjectConfig.is_desktop_mode', side_effect=true_fn) 59 | @mock.patch('clickable.commands.install.logger.debug', side_effect=empty_fn) 60 | def test_skip_desktop_mode(self, mock_logger_debug, mock_desktop_mode): 61 | self.command.run() 62 | 63 | mock_logger_debug.assert_called_once_with(ANY) 64 | mock_desktop_mode.assert_called_once_with() 65 | 66 | @mock.patch('clickable.commands.install.logger.debug', side_effect=empty_fn) 67 | def test_skip_container_mode(self, mock_logger_debug): 68 | self.config.container_mode = True 69 | self.command.run() 70 | 71 | mock_logger_debug.assert_called_once_with(ANY) 72 | -------------------------------------------------------------------------------- /clickable/commands/shell.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import shlex 4 | 5 | from .base import Command 6 | from clickable.utils import ( 7 | run_subprocess_call, 8 | run_subprocess_check_output, 9 | ) 10 | from clickable.logger import logger 11 | from clickable.exceptions import ClickableException 12 | 13 | 14 | class ShellCommand(Command): 15 | aliases = ['ssh'] 16 | name = 'shell' 17 | help = 'Opens a shell on the device via ssh' 18 | 19 | def toggle_ssh(self, on=False): 20 | command = 'sudo -u phablet bash -c \'/usr/bin/gdbus call -y -d com.canonical.PropertyService -o /com/canonical/PropertyService -m com.canonical.PropertyService.SetProperty ssh {}\''.format( 21 | 'true' if on else 'false' 22 | ) 23 | 24 | adb_args = '' 25 | if self.config.device_serial_number: 26 | adb_args = '-s {}'.format(self.config.device_serial_number) 27 | 28 | run_subprocess_call(shlex.split('adb {} shell "{}"'.format(adb_args, command)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 29 | 30 | def run(self, path_arg=None): 31 | ''' 32 | Inspired by http://bazaar.launchpad.net/~phablet-team/phablet-tools/trunk/view/head:/phablet-shell 33 | ''' 34 | 35 | if self.config.ssh: 36 | subprocess.check_call(shlex.split('ssh phablet@{}'.format(self.config.ssh))) 37 | else: 38 | self.device.check_any_attached() 39 | 40 | adb_args = '' 41 | if self.config.device_serial_number: 42 | adb_args = '-s {}'.format(self.config.device_serial_number) 43 | else: 44 | self.device.check_multiple_attached() 45 | 46 | output = run_subprocess_check_output(shlex.split('adb {} shell pgrep sshd'.format(adb_args))).split() 47 | if not output: 48 | self.toggle_ssh(on=True) 49 | 50 | # Use the usb cable rather than worrying about going over wifi 51 | port = 0 52 | for p in range(2222, 2299): 53 | error_code = run_subprocess_call(shlex.split('adb {} forward tcp:{} tcp:22'.format(adb_args, p)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 54 | if error_code == 0: 55 | port = p 56 | break 57 | 58 | if port == 0: 59 | raise ClickableException('Failed to open a port to the device') 60 | 61 | # Purge the device host key so that SSH doesn't print a scary warning about it 62 | # (it changes every time the device is reflashed and this is expected) 63 | known_hosts = os.path.expanduser('~/.ssh/known_hosts') 64 | subprocess.check_call(shlex.split('touch {}'.format(known_hosts))) 65 | subprocess.check_call(shlex.split('ssh-keygen -f {} -R [localhost]:{}'.format(known_hosts, port))) 66 | 67 | id_pub = os.path.expanduser('~/.ssh/id_rsa.pub') 68 | if not os.path.isfile(id_pub): 69 | raise ClickableException('Could not find a ssh public key at "{}", please generate one and try again'.format(id_pub)) 70 | 71 | with open(id_pub, 'r') as f: 72 | public_key = f.read().strip() 73 | 74 | self.device.run_command('[ -d ~/.ssh ] || mkdir ~/.ssh', cwd=self.config.cwd) 75 | self.device.run_command('touch ~/.ssh/authorized_keys', cwd=self.config.cwd) 76 | 77 | output = run_subprocess_check_output('adb {} shell "grep \\"{}\\" ~/.ssh/authorized_keys"'.format(adb_args, public_key), shell=True).strip() 78 | if not output or 'No such file or directory' in output: 79 | logger.info('Inserting ssh public key on the connected device') 80 | self.device.run_command('echo \"{}\" >>~/.ssh/authorized_keys'.format(public_key), cwd=self.config.cwd) 81 | self.device.run_command('chmod 700 ~/.ssh', cwd=self.config.cwd) 82 | self.device.run_command('chmod 600 ~/.ssh/authorized_keys', cwd=self.config.cwd) 83 | 84 | subprocess.check_call(shlex.split('ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p {} phablet@localhost'.format(port))) 85 | self.toggle_ssh(on=False) 86 | -------------------------------------------------------------------------------- /clickable/builders/rust.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import shutil 4 | 5 | from .base import Builder 6 | from clickable.config.project import ProjectConfig 7 | from clickable.config.constants import Constants 8 | from clickable.exceptions import ClickableException 9 | from clickable.utils import find 10 | 11 | 12 | rust_arch_target_mapping = { 13 | 'amd64': 'x86_64-unknown-linux-gnu', 14 | 'armhf': 'armv7-unknown-linux-gnueabihf', 15 | 'arm64': 'aarch64-unknown-linux-gnu', 16 | } 17 | 18 | 19 | class RustBuilder(Builder): 20 | name = Constants.RUST 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | 25 | self.paths_to_ignore = self._find_click_assets() 26 | self.paths_to_ignore.extend([ 27 | # Click stuff 28 | os.path.abspath(self.config.install_dir), 29 | os.path.abspath(self.config.build_dir), 30 | os.path.abspath(self._get_base_build_dir()), 31 | 'clickable.json', 32 | os.path.abspath(os.path.join(self.config.cwd, 'Cargo.toml')), 33 | os.path.abspath(os.path.join(self.config.cwd, 'Cargo.lock')), 34 | os.path.abspath(os.path.join(self.config.cwd, 'target')), 35 | ]) 36 | self.paths_to_ignore.extend(self.config.ignore) 37 | 38 | def _get_base_build_dir(self): 39 | base_build_dir = self.config.build_dir 40 | 41 | if self.config.arch_triplet in base_build_dir: 42 | base_build_dir = base_build_dir.split(self.config.arch_triplet)[0] 43 | 44 | return base_build_dir 45 | 46 | @property 47 | def _cargo_target(self): 48 | if self.config.arch not in rust_arch_target_mapping: 49 | raise ClickableException( 50 | 'Architecture "{}" unsupported by rust builder'.format(self.config.arch)) 51 | return rust_arch_target_mapping[self.config.arch] 52 | 53 | def _find_click_assets(self): 54 | return [ 55 | find(['manifest.json'], self.config.cwd, ignore_dir=self._get_base_build_dir()), 56 | find(['.apparmor'], self.config.cwd, ignore_dir=self._get_base_build_dir(), extensions_only=True), 57 | find(['.desktop'], self.config.cwd, ignore_dir=self._get_base_build_dir(), extensions_only=True), 58 | ] 59 | 60 | def _ignore(self, path, contents): 61 | ignored = [] 62 | for content in contents: 63 | abs_path = os.path.abspath(os.path.join(path, content)) 64 | if ( 65 | abs_path in self.paths_to_ignore 66 | or content in self.paths_to_ignore 67 | or os.path.splitext(content)[1] == '.rs' 68 | ): 69 | ignored += [content] 70 | return ignored 71 | 72 | def build(self): 73 | # Remove old artifacts unless the dirty option is active 74 | if not self.config.dirty and os.path.isdir(self.config.install_dir): 75 | shutil.rmtree(self.config.install_dir) 76 | 77 | # Copy project assets 78 | shutil.copytree(self.config.cwd, 79 | self.config.install_dir, ignore=self._ignore) 80 | 81 | # Copy click assets 82 | target_dir = self.config.app_bin_dir 83 | 84 | 85 | os.makedirs(target_dir, exist_ok=True) 86 | assets = self._find_click_assets() 87 | for asset in assets: 88 | shutil.copy2(asset, self.config.install_dir) 89 | 90 | # Build using cargo 91 | cargo_command = 'cargo build {} --target {}' \ 92 | .format('--release' if not self.config.debug_build else '', 93 | self._cargo_target) 94 | self.config.container.run_command(cargo_command) 95 | 96 | # There could be more than one executable 97 | executables = glob.glob('{}/target/{}/{}/*' 98 | .format(self.config.cwd, 99 | self._cargo_target, 100 | 'debug' if self.config.debug_build else 'release')) 101 | for filename in filter(lambda f: os.path.isfile(f), executables): 102 | shutil.copy2(filename, '{}/{}'.format( 103 | target_dir, 104 | os.path.basename(filename)), 105 | ) 106 | -------------------------------------------------------------------------------- /tests/unit/test_architectures.py: -------------------------------------------------------------------------------- 1 | from clickable import Clickable 2 | from .base_test import UnitTest 3 | 4 | class TestArchitectures(UnitTest): 5 | def run_arch_test(self, 6 | arch=None, 7 | arch_agnostic_builder=False, 8 | build_cmd=True, 9 | restrict_arch_env=None, 10 | restrict_arch=None, 11 | expect_exception=False): 12 | config_env = {} 13 | if restrict_arch_env: 14 | config_env['CLICKABLE_ARCH'] = restrict_arch_env 15 | 16 | config_json = {} 17 | if arch_agnostic_builder: 18 | config_json["builder"] = "pure" 19 | else: 20 | config_json["builder"] = "cmake" 21 | if restrict_arch: 22 | config_json["restrict_arch"] = restrict_arch 23 | 24 | cli_args = [] 25 | if arch: 26 | cli_args += ["--arch", arch] 27 | 28 | parser = Clickable.create_parser("Unit Test Call") 29 | run_args = parser.parse_args(cli_args) 30 | 31 | commands = ['no_command'] 32 | if build_cmd: 33 | commands.append('build') 34 | 35 | self.setUpConfig( 36 | expect_exception = expect_exception, 37 | mock_config_json = config_json, 38 | mock_config_env = config_env, 39 | args = run_args, 40 | commands = commands, 41 | ) 42 | 43 | if arch: 44 | expected_arch = arch 45 | elif restrict_arch: 46 | expected_arch = restrict_arch 47 | elif arch_agnostic_builder: 48 | expected_arch = "all" 49 | elif restrict_arch_env: 50 | expected_arch = restrict_arch_env 51 | else: 52 | expected_arch = "armhf" 53 | 54 | if not expect_exception: 55 | self.assertEqual(expected_arch, self.config.arch) 56 | 57 | def test_arch(self): 58 | self.run_arch_test('all') 59 | self.run_arch_test('amd64') 60 | self.run_arch_test('arm64') 61 | self.run_arch_test('armhf') 62 | self.run_arch_test(arch=None) 63 | 64 | def test_default_arch(self): 65 | self.run_arch_test(arch=None) 66 | self.run_arch_test(arch=None, restrict_arch_env='arm64') 67 | self.run_arch_test(arch=None, restrict_arch_env='arm64') 68 | self.run_arch_test(arch=None, restrict_arch_env='arm64', 69 | arch_agnostic_builder=True) 70 | self.run_arch_test(arch=None, restrict_arch_env='arm64', 71 | restrict_arch='all') 72 | self.run_arch_test(arch='all', restrict_arch_env='arm64') 73 | 74 | def test_arch_agnostic(self): 75 | self.run_arch_test('all', arch_agnostic_builder=True) 76 | self.run_arch_test(arch=None, arch_agnostic_builder=True) 77 | 78 | def test_fail_arch_agnostic(self): 79 | self.run_arch_test('armhf', arch_agnostic_builder=True, 80 | expect_exception=True) 81 | self.run_arch_test('armhf', arch_agnostic_builder=True, 82 | build_cmd=False, expect_exception=True) 83 | 84 | def test_restricted_arch_env(self): 85 | self.run_arch_test('all', restrict_arch_env='armhf') 86 | self.run_arch_test(arch=None, arch_agnostic_builder=True, 87 | restrict_arch_env='arm64') 88 | self.run_arch_test('amd64', restrict_arch_env='armhf', build_cmd=False) 89 | self.run_arch_test('arm64', restrict_arch_env='armhf', build_cmd=False) 90 | self.run_arch_test('armhf', restrict_arch_env='arm64', build_cmd=False) 91 | 92 | def test_fail_in_restricted_arch_env(self): 93 | self.run_arch_test('amd64', restrict_arch_env='armhf', 94 | expect_exception=True) 95 | self.run_arch_test('amd64', restrict_arch_env='all', 96 | expect_exception=True) 97 | 98 | def test_restricted_arch(self): 99 | self.run_arch_test('all', restrict_arch='all') 100 | self.run_arch_test('amd64', restrict_arch='amd64') 101 | 102 | def test_fail_in_restricted_arch(self): 103 | self.run_arch_test('amd64', restrict_arch='armhf', 104 | expect_exception=True) 105 | self.run_arch_test('amd64', restrict_arch='armhf', build_cmd=False, 106 | expect_exception=True) 107 | self.run_arch_test('all', restrict_arch='arm64', build_cmd=False, 108 | expect_exception=True) 109 | self.run_arch_test('arm64', restrict_arch='all', build_cmd=False, 110 | expect_exception=True) 111 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ===== 5 | 6 | Getting Started 7 | --------------- 8 | 9 | At this point it is assumed that you have completed the :ref:`installation 10 | process ` 11 | 12 | To find out all supported command line arguments run ``clickable --help``. 13 | 14 | You can get started with using clickable with an existing Ubuntu Touch app. 15 | You can use clickable with apps generated from the old Ubuntu Touch SDK IDE 16 | or you can start fresh by running ``clickable create`` which is outlined in more 17 | detail on the previous :ref:`getting started ` page. 18 | 19 | To run the default set of sub-commands, simply run ``clickable`` in the root directory 20 | of your app's code. Clickable will attempt to auto detect the 21 | :ref:`build template ` and other configuration options. 22 | 23 | Note: The first time you run ``clickable`` in your app directory, behind the 24 | scenes it will download a new Docker container which is about 1GB in size - so 25 | plan your time and data transfer environment accordingly. This will only happen 26 | the first time you build your app for a specific architecture and when you run 27 | ``clickable update``. 28 | 29 | Running the default sub-commands will: 30 | 31 | 1) Clean the build directory (by default ``./build//app``) 32 | 2) Build the app 33 | 3) Build the click package (can be found in the build directory) 34 | 4) Install the app on your phone (By default this uses adb, see below if you want to use ssh) 35 | 5) Kill the running app on the phone 36 | 6) Launch the app on your phone 37 | 38 | Note: ensure your device is in `developer mode `__ 39 | for the app to be installed when using adb or `enable ssh `__ 40 | when using ssh. 41 | 42 | Configuration 43 | ------------- 44 | It is recommend to specify a configuration file in the 45 | :ref:`clickable.json format ` with ``--config``. If not 46 | specified, clickable will look for an optional configuration file called 47 | ``clickable.json`` in the current directory. If there is none Clickable will 48 | ask if it should attempt to detect the type of app and choose a fitting 49 | :ref:`builder ` with default configuration. 50 | 51 | .. _ssh: 52 | 53 | Connecting to a device over ssh 54 | ------------------------------- 55 | 56 | By default the device is connected to via adb. 57 | If you want to access a device over ssh you need to either specify the device 58 | IP address or hostname on the command line (ex: ``clickable logs --ssh 192.168.1.10`` ) or you 59 | can use the ``CLICKABLE_SSH`` env var. Make sure to `enable ssh `__ 60 | on your device for this to work. 61 | 62 | .. _multiple-devices: 63 | 64 | Multiple connected devices 65 | -------------------------- 66 | 67 | By default clickable assumes that there is only one device connected to your 68 | computer via adb. If you have multiple devices attached to your computer you 69 | can specify which device to install/launch/etc on by using the flag 70 | ``--serial-number`` or ``-s`` for short. You can get the serial number 71 | by running ``clickable devices``. 72 | 73 | App Manifest 74 | ------------ 75 | 76 | The ``architecture`` and ``framework`` fields in the ``manifest.json`` need to be set according 77 | to the architecture the app is build for (``--arch``) and the minimum framework version it 78 | requires, e.g. depending on the QT Version (:ref:`qt_version `). 79 | To let Clickable automatically set those fields, leave them empty or set them to 80 | ``@CLICK_ARCH@`` and ``@CLICK_FRAMEWORK@`` respectively. 81 | 82 | Note: The app templates provided by Clickable make use of CMake's ``configure()`` to set 83 | the fields in the ``manifest.json``. 84 | 85 | Advanced Usage 86 | -------------- 87 | 88 | .. _lxd: 89 | 90 | Running Clickable in an LXD container 91 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 92 | 93 | It is possible to run ``clickable`` in a container itself, using ``lxd``. This is not using ``--container-mode``, but allowing ``clickable`` to create docker containers as normal, but inside the existing ``lxd`` container. This may fail with a permissions error when mounting ``/proc``: 94 | 95 | .. code-block:: bash 96 | 97 | docker: Error response from daemon: OCI runtime create failed: container_linux.go:349: starting container process caused "process_linux.go:449: container init caused \"rootfs_linux.go:58: mounting \\\"proc\\\" to rootfs \\\"/var/lib/docker/vfs/dir/bffeb203fe06662876a521b1bea3b74e4d5c6ea3535352215c199c75836aa925\\\" at \\\"/proc\\\" caused \\\"permission denied\\\"\"": unknown. 98 | 99 | If this error occurs then ``lxd`` needs to be `configured to allow nested containers ` on the host: 100 | 101 | .. code-block:: bash 102 | 103 | lxc stop your-container-name 104 | lxc config set your-container-name security.nesting true 105 | lxc start your-container-name 106 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # clickable documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Dec 15 10:32:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix(es) of source filenames. 40 | # You can specify multiple suffix as a list of string: 41 | # 42 | # source_suffix = ['.rst', '.md'] 43 | source_suffix = '.rst' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'Clickable' 50 | copyright = '2020 Brian Douglass, Jonatan Hatakeyama Zeidler' 51 | author = 'Clickable Team' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '6.24.1' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '6.24.1' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This patterns also effect to html_static_path and html_extra_path 72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = 'sphinx' 76 | 77 | # If true, `todo` and `todoList` produce output, else they produce nothing. 78 | todo_include_todos = False 79 | 80 | 81 | # -- Options for HTML output ---------------------------------------------- 82 | 83 | # The theme to use for HTML and HTML Help pages. See the documentation for 84 | # a list of builtin themes. 85 | # 86 | import sphinx_rtd_theme 87 | 88 | html_theme = "sphinx_rtd_theme" 89 | html_logo = '_static/images/logo.png' 90 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 91 | 92 | # Theme options are theme-specific and customize the look and feel of a theme 93 | # further. For a list of options available for each theme, see the 94 | # documentation. 95 | # 96 | # html_theme_options = {} 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_static_path = ['_static'] 102 | 103 | # Custom sidebar templates, must be a dictionary that maps document names 104 | # to template names. 105 | # 106 | # This is required for the alabaster theme 107 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 108 | html_sidebars = { 109 | '**': [ 110 | 'relations.html', # needs 'show_related': True theme option to display 111 | 'searchbox.html', 112 | ] 113 | } 114 | 115 | 116 | # -- Options for HTMLHelp output ------------------------------------------ 117 | 118 | # Output file base name for HTML help builder. 119 | htmlhelp_basename = 'clickabledoc' 120 | 121 | 122 | # -- Options for LaTeX output --------------------------------------------- 123 | 124 | latex_elements = { 125 | # The paper size ('letterpaper' or 'a4paper'). 126 | # 127 | # 'papersize': 'letterpaper', 128 | 129 | # The font size ('10pt', '11pt' or '12pt'). 130 | # 131 | # 'pointsize': '10pt', 132 | 133 | # Additional stuff for the LaTeX preamble. 134 | # 135 | # 'preamble': '', 136 | 137 | # Latex figure (float) alignment 138 | # 139 | # 'figure_align': 'htbp', 140 | } 141 | 142 | # Grouping the document tree into LaTeX files. List of tuples 143 | # (source start file, target name, title, 144 | # author, documentclass [howto, manual, or own class]). 145 | latex_documents = [ 146 | (master_doc, 'clickable.tex', 'Clickable Documentation', 147 | 'Brian Douglass', 'manual'), 148 | ] 149 | 150 | 151 | # -- Options for manual page output --------------------------------------- 152 | 153 | # One entry per manual page. List of tuples 154 | # (source start file, name, description, authors, manual section). 155 | man_pages = [ 156 | (master_doc, 'Clickable', 'Clickable Documentation', 157 | [author], 1) 158 | ] 159 | 160 | 161 | # -- Options for Texinfo output ------------------------------------------- 162 | 163 | # Grouping the document tree into Texinfo files. List of tuples 164 | # (source start file, target name, title, author, 165 | # dir menu entry, description, category) 166 | texinfo_documents = [ 167 | (master_doc, 'Clickable', 'Clickable Documentation', 168 | author, 'Clickable', 'Compile, build, and deploy Ubuntu Touch click packages all from the command line.', 169 | 'Miscellaneous'), 170 | ] 171 | -------------------------------------------------------------------------------- /clickable/commands/build.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import shutil 4 | 5 | from .base import Command 6 | from .review import ReviewCommand 7 | from clickable.utils import ( 8 | get_builders, 9 | run_subprocess_check_call, 10 | makedirs, 11 | is_sub_dir, 12 | ) 13 | from clickable.logger import logger 14 | from clickable.exceptions import ClickableException 15 | 16 | 17 | class BuildCommand(Command): 18 | aliases = [] 19 | name = 'build' 20 | help = 'Compile the app' 21 | 22 | def run(self, path_arg=None): 23 | try: 24 | os.makedirs(self.config.build_dir, exist_ok=True) 25 | except Exception: 26 | logger.warning('Failed to create the build directory: {}'.format(str(sys.exc_info()[0]))) 27 | 28 | try: 29 | os.makedirs(self.config.build_home, exist_ok=True) 30 | except Exception: 31 | logger.warning('Failed to create the build home directory: {}'.format(str(sys.exc_info()[0]))) 32 | 33 | self.config.container.setup() 34 | 35 | if self.config.prebuild: 36 | run_subprocess_check_call(self.config.prebuild, cwd=self.config.cwd, shell=True) 37 | 38 | self.build() 39 | 40 | self.install_additional_files() 41 | 42 | if self.config.postbuild: 43 | run_subprocess_check_call(self.config.postbuild, cwd=self.config.build_dir, shell=True) 44 | 45 | self.click_build() 46 | 47 | if not self.config.skip_review: 48 | review = ReviewCommand(self.config) 49 | review.check(self.click_path, raise_on_error=False) 50 | 51 | def build(self): 52 | builder_classes = get_builders() 53 | builder = builder_classes[self.config.builder](self.config, self.device) 54 | builder.build() 55 | 56 | def install_files(self, pattern, dest_dir): 57 | if not is_sub_dir(dest_dir, self.config.install_dir): 58 | dest_dir = os.path.abspath(self.config.install_dir + "/" + dest_dir) 59 | 60 | makedirs(dest_dir) 61 | if '"' in pattern: 62 | # Make sure one cannot run random bash code through the "ls" command 63 | raise ClickableException("install_* patterns must not contain any '\"' quotation character.") 64 | 65 | command = 'ls -d "{}"'.format(pattern) 66 | files = self.config.container.run_command(command, get_output=True).split() 67 | 68 | logger.info("Installing {}".format(", ".join(files))) 69 | self.config.container.pull_files(files, dest_dir) 70 | 71 | def install_qml_files(self, pattern, dest_dir): 72 | if '*' in pattern: 73 | self.install_files(pattern, dest_dir) 74 | else: 75 | command = 'cat {}'.format(os.path.join(pattern, 'qmldir')) 76 | qmldir = self.config.container.run_command(command, get_output=True) 77 | module = None 78 | for line in qmldir.split('\n'): 79 | if line.startswith('module'): 80 | module = line.split(' ')[1] 81 | 82 | if module: 83 | self.install_files(pattern, os.path.join( 84 | dest_dir, *module.split('.')[:-1]) 85 | ) 86 | else: 87 | self.install_files(pattern, dest_dir) 88 | 89 | def install_additional_files(self): 90 | for p in self.config.install_lib: 91 | self.install_files(p, os.path.join(self.config.install_dir, 92 | self.config.app_lib_dir)) 93 | for p in self.config.install_bin: 94 | self.install_files(p, os.path.join(self.config.install_dir, 95 | self.config.app_bin_dir)) 96 | for p in self.config.install_qml: 97 | self.install_qml_files(p, os.path.join(self.config.install_dir, 98 | self.config.app_qml_dir)) 99 | for p, dest in self.config.install_data.items(): 100 | self.install_files(p, dest) 101 | 102 | def set_arch(self, manifest): 103 | arch = manifest.get('architecture', None) 104 | 105 | if arch == '@CLICK_ARCH@' or arch == '': 106 | manifest['architecture'] = self.config.arch 107 | return True 108 | 109 | if arch != self.config.arch: 110 | raise ClickableException('Clickable is building for architecture "{}", but "{}" is specified in the manifest. You can set the architecture field to @CLICK_ARCH@ to let Clickable set the architecture field automatically.'.format( 111 | self.config.arch, arch)) 112 | 113 | return False 114 | 115 | def set_framework(self, manifest): 116 | framework = manifest.get('framework', None) 117 | 118 | if framework == '@CLICK_FRAMEWORK@' or framework == '': 119 | manifest['framework'] = self.config.framework 120 | return True 121 | 122 | if framework != self.config.framework: 123 | logger.warning('Framework in manifest is "{}", Clickable expected "{}".'.format( 124 | framework, self.config.framework)) 125 | 126 | return False 127 | 128 | def manipulate_manifest(self): 129 | manifest = self.config.install_files.get_manifest() 130 | has_changed = False 131 | 132 | if self.set_arch(manifest): 133 | has_changed = True 134 | 135 | if self.set_framework(manifest): 136 | has_changed = True 137 | 138 | if has_changed: 139 | self.config.install_files.write_manifest(manifest) 140 | 141 | def click_build(self): 142 | self.manipulate_manifest() 143 | 144 | command = 'click build {} --no-validate'.format(self.config.install_dir) 145 | self.config.container.run_command(command) 146 | 147 | click = self.config.install_files.get_click_filename() 148 | self.click_path = os.path.join(self.config.build_dir, click) 149 | 150 | if self.config.click_output: 151 | output_file = os.path.join(self.config.click_output, click) 152 | 153 | if not os.path.exists(self.config.click_output): 154 | os.makedirs(self.config.click_output) 155 | 156 | shutil.copyfile(self.click_path, output_file) 157 | self.click_path = output_file 158 | 159 | logger.debug('Click outputted to {}'.format(self.click_path)) 160 | -------------------------------------------------------------------------------- /tests/unit/test_ide_qtcreator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from clickable.commands.docker.docker_config import DockerConfig 4 | from clickable.commands.idedelegates.qtcreator import QtCreatorDelegate 5 | 6 | from .base_test import UnitTest 7 | from unittest import mock 8 | 9 | class TestIdeQtCreatorCommand(UnitTest): 10 | 11 | def setUp(self): 12 | self.setUpConfig() 13 | self.docker_config = DockerConfig() 14 | self.docker_config.add_environment_variables( 15 | { 16 | "LD_LIBRARY_PATH":"/usr/bin", 17 | "CLICK_LD_LIBRARY_PATH":"/tmp/fake/qmlproject/build/app/install", 18 | "QML2_IMPORT_PATH":"/tmp/qmllibs", 19 | "CLICK_QML2_IMPORT_PATH":"/tmp/fake/qmlproject/build/app/install", 20 | "CLICK_PATH":"/tmp/fake/qmlproject/build/app/install/lib", 21 | "PATH":"/usr/bin" 22 | } 23 | ) 24 | 25 | self.idedelegate = QtCreatorDelegate() 26 | self.idedelegate.clickable_dir = '/tmp/tests/.clickable' 27 | self.idedelegate.project_path = '/tmp/tests/qmlproject' 28 | self.output_file = os.path.join(self.idedelegate.project_path, 'CMakeLists.txt.user.shared') 29 | 30 | self.idedelegate.target_settings_path = os.path.join(self.idedelegate.clickable_dir ,'QtProject') 31 | 32 | os.makedirs(self.idedelegate.project_path) 33 | 34 | 35 | def test_command_overrided(self): 36 | 37 | #path should not be added to qtcreator command if no clickable.json found 38 | path_arg = self.idedelegate.override_command('qtcreator') 39 | self.assertEqual(path_arg, 'qtcreator -settingspath {} '.format(self.idedelegate.clickable_dir)) 40 | 41 | #create a fake clickable.json file, overrided path should now be with the current directory 42 | open(os.path.join(self.idedelegate.project_path,'clickable.json'), 'a') 43 | path_arg = self.idedelegate.override_command('qtcreator') 44 | self.assertEqual(path_arg, 'qtcreator -settingspath {} {}'.format(self.idedelegate.clickable_dir, self.idedelegate.project_path)) 45 | 46 | def test_initialize_qtcreator_conf(self): 47 | 48 | self.idedelegate.before_run(None, self.docker_config) 49 | self.assertTrue(os.path.isdir('/tmp/tests/.clickable/QtProject')) 50 | 51 | def test_recurse_replace(self): 52 | 53 | final_command = self.idedelegate.recurse_replace('qmlscene --ok=\"args\"', {}) 54 | self.assertEquals('qmlscene --ok=\"args\"', final_command) 55 | 56 | final_command = self.idedelegate.recurse_replace('\"qmlscene --ok=\"args\"\"', {}) 57 | self.assertEquals('\"qmlscene --ok=\"args\"\"', final_command) 58 | 59 | cmake_vars = { 60 | 'FAKE':'qmlscene' 61 | } 62 | final_command = self.idedelegate.recurse_replace('${FAKE} --ok=\"args\"', cmake_vars) 63 | self.assertEquals('qmlscene --ok=\"args\"', final_command) 64 | 65 | #test with recursive vars 66 | cmake_vars = { 67 | 'FAKE_SUBVAR':'share/foo', 68 | 'FAKE_VAR':'${FAKE_SUBVAR}/hello' 69 | } 70 | final_command = self.idedelegate.recurse_replace('${FAKE_VAR} --args someargs', cmake_vars) 71 | self.assertEquals('share/foo/hello --args someargs', final_command) 72 | 73 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_executable', return_value='') 74 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_exec_args') 75 | def test_init_cmake_project_no_exe(self, find_any_executable_mock, find_any_exec_args_mock): 76 | 77 | #if Exec not found in desktop, should do nothing 78 | self.idedelegate.init_cmake_project(self.config, self.docker_config) 79 | self.assertFalse(os.path.isfile(self.output_file)) 80 | 81 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_executable', return_value='fake_exe') 82 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_exec_args') 83 | def test_init_cmake_project_no_to_prompt(self, find_any_executable_mock, find_any_exec_args_mock): 84 | #mock prompt 85 | original_input = mock.builtins.input 86 | mock.builtins.input = lambda _: "no" 87 | 88 | #user choose not to let clickable generate config 89 | self.idedelegate.init_cmake_project(self.config, self.docker_config) 90 | self.assertFalse(os.path.isfile(self.output_file)) 91 | 92 | mock.builtins.input = original_input 93 | 94 | 95 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_executable', return_value='fake_exe') 96 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_exec_args', return_value=[]) 97 | def test_init_cmake_project(self, find_any_executable_mock, find_any_exec_args_mock): 98 | #mock prompt 99 | original_input = mock.builtins.input 100 | mock.builtins.input = lambda _: "yes" 101 | 102 | #now he is ok to let clickable build the configuration template 103 | self.idedelegate.init_cmake_project(self.config, self.docker_config) 104 | self.assertTrue(os.path.isfile(self.output_file)) 105 | #test an example variable that have been replaced 106 | self.assertTrue(open(self.output_file, 'r').read().find('CustomExecutableRunConfiguration.Arguments">fake_exe')) 107 | 108 | mock.builtins.input = original_input 109 | 110 | 111 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_executable', return_value='@FAKE_EXE@') 112 | @mock.patch('clickable.config.file_helpers.ProjectFiles.find_any_exec_args', return_value=[]) 113 | def test_init_cmake_project_exe_as_var(self, find_any_executable_mock, find_any_exec_args_mock): 114 | #mock prompt 115 | original_input = mock.builtins.input 116 | mock.builtins.input = lambda _: "yes" 117 | 118 | #Exec command as variable 119 | cmake_file = open(os.path.join(self.idedelegate.project_path,'CMakeLists.txt'), 'w') 120 | cmake_file.write("set(FAKE_EXE \"qmlscene --ok=\"args\"\")") 121 | cmake_file.close() 122 | 123 | self.idedelegate.init_cmake_project(self.config, self.docker_config) 124 | #test that final exe var is found 125 | generated_shared_file = open(self.output_file, 'r').read() 126 | self.assertTrue(generated_shared_file.find('CustomExecutableRunConfiguration.Arguments">qmlscene')) 127 | self.assertTrue(generated_shared_file.find('CustomExecutableRunConfiguration.Arguments">--ok="args"')) 128 | 129 | mock.builtins.input = original_input 130 | 131 | def tearDown(self): 132 | shutil.rmtree(self.idedelegate.project_path, ignore_errors=True) 133 | -------------------------------------------------------------------------------- /clickable/config/clickable.schema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://clickable-ut.dev/clickable.schema", 4 | "title": "clickable.json", 5 | "description": "The Clickable configuration file", 6 | "type": "object", 7 | "properties": { 8 | "clickable_minimum_required": { 9 | "type": "string", 10 | "pattern": "^\\d+(\\.\\d+)*$" 11 | }, 12 | "restrict_arch": { 13 | "type": "string", 14 | "enum": [ 15 | "armhf", 16 | "arm64", 17 | "amd64", 18 | "all" 19 | ] 20 | }, 21 | "arch": { 22 | "type": "string", 23 | "enum": [ 24 | "armhf", 25 | "arm64", 26 | "amd64", 27 | "all" 28 | ] 29 | }, 30 | "builder": { 31 | "type": "string", 32 | "enum": [ 33 | "pure-qml-qmake", 34 | "qmake", 35 | "pure-qml-cmake", 36 | "cmake", 37 | "custom", 38 | "cordova", 39 | "pure", 40 | "python", 41 | "go", 42 | "rust", 43 | "precompiled" 44 | ] 45 | }, 46 | "template": { 47 | "type": "string", 48 | "enum": [ 49 | "pure-qml-qmake", 50 | "qmake", 51 | "pure-qml-cmake", 52 | "cmake", 53 | "custom", 54 | "cordova", 55 | "pure", 56 | "python", 57 | "go", 58 | "rust", 59 | "precompiled", 60 | "" 61 | ] 62 | }, 63 | "framework": {"type": "string"}, 64 | "qt_version": {"type": "string"}, 65 | "prebuild": {"type": "string"}, 66 | "build": {"type": "string"}, 67 | "postbuild": {"type": "string"}, 68 | "postmake": {"type": "string"}, 69 | "launch": {"type": "string"}, 70 | "build_dir": {"type": "string"}, 71 | "src_dir": {"type": "string"}, 72 | "install_dir": {"type": "string"}, 73 | "root_dir": {"type": "string"}, 74 | "kill": {"type": "string"}, 75 | "scripts": { 76 | "type": "object", 77 | "additionalProperties": { "type": "string"} 78 | }, 79 | "default": { 80 | "type": ["string","array"], 81 | "items": {"type": "string"} 82 | }, 83 | "dependencies_host": { 84 | "type": ["string","array"], 85 | "items": {"type": "string"} 86 | }, 87 | "dependencies_build": { 88 | "type": ["string","array"], 89 | "items": {"type": "string"} 90 | }, 91 | "dependencies_target": { 92 | "type": ["string","array"], 93 | "items": {"type": "string"} 94 | }, 95 | "dependencies_ppa": { 96 | "type": ["string","array"], 97 | "items": {"type": "string"} 98 | }, 99 | "install_lib": { 100 | "type": ["string","array"], 101 | "items": {"type": "string"} 102 | }, 103 | "install_bin": { 104 | "type": ["string","array"], 105 | "items": {"type": "string"} 106 | }, 107 | "install_qml": { 108 | "type": ["string","array"], 109 | "items": {"type": "string"} 110 | }, 111 | "install_data": { 112 | "type": "object", 113 | "additionalProperties": { "type": "string"} 114 | }, 115 | "docker_image": {"type": "string"}, 116 | "ignore": { 117 | "type": ["string","array"], 118 | "items": {"type": "string"} 119 | }, 120 | "gopath": {"type": "string"}, 121 | "cargo_home": {"type": "string"}, 122 | "build_args": { 123 | "type": ["string","array"], 124 | "items": {"type": "string"} 125 | }, 126 | "env_vars": { 127 | "type": "object", 128 | "additionalProperties": { "type": "string"} 129 | }, 130 | "make_args": { 131 | "type": ["string","array"], 132 | "items": {"type": "string"} 133 | }, 134 | "make_jobs": { 135 | "type": "integer", 136 | "minimum": 1 137 | }, 138 | "dirty": {"type": "boolean"}, 139 | "test": {"type": "string"}, 140 | "image_setup": { 141 | "type": ["object"], 142 | "properties": { 143 | "env": { 144 | "type": "object", 145 | "additionalProperties": { "type": "string"} 146 | }, 147 | "run": { 148 | "type": "array", 149 | "items": {"type": "string"} 150 | } 151 | } 152 | }, 153 | "libraries": { 154 | "type": ["object"], 155 | "additionalProperties": { 156 | "type": "object", 157 | "properties": { 158 | "prebuild": {"type": "string"}, 159 | "build": {"type": "string"}, 160 | "postbuild": {"type": "string"}, 161 | "postmake": {"type": "string"}, 162 | "build_args": { 163 | "type": ["string","array"], 164 | "items": {"type": "string"} 165 | }, 166 | "env_vars": { 167 | "type": "object", 168 | "additionalProperties": { "type": "string"} 169 | }, 170 | "make_args": { 171 | "type": ["string","array"], 172 | "items": {"type": "string"} 173 | }, 174 | "make_jobs": { 175 | "type": "integer", 176 | "minimum": 1 177 | }, 178 | "docker_image": {"type": "string"}, 179 | "dependencies_build": { 180 | "type": ["string","array"], 181 | "items": {"type": "string"} 182 | }, 183 | "dependencies_host": { 184 | "type": ["string","array"], 185 | "items": {"type": "string"} 186 | }, 187 | "dependencies_target": { 188 | "type": ["string","array"], 189 | "items": {"type": "string"} 190 | }, 191 | "dependencies_ppa": { 192 | "type": ["string","array"], 193 | "items": {"type": "string"} 194 | }, 195 | "builder": { 196 | "type": "string", 197 | "enum": [ 198 | "cmake", 199 | "qmake", 200 | "custom" 201 | ] 202 | }, 203 | "template": { 204 | "type": "string", 205 | "enum": [ 206 | "cmake", 207 | "qmake", 208 | "custom" 209 | ] 210 | }, 211 | "build_dir": {"type": "string"}, 212 | "src_dir": {"type": "string"}, 213 | "install_dir": {"type": "string"}, 214 | "test": {"type": "string"}, 215 | "image_setup": { 216 | "type": ["object"], 217 | "properties": { 218 | "run": { 219 | "type": "array", 220 | "items": {"type": "string"} 221 | } 222 | } 223 | } 224 | }, 225 | "additionalProperties": false 226 | } 227 | } 228 | }, 229 | "additionalProperties": false 230 | } 231 | -------------------------------------------------------------------------------- /clickable/config/file_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import json 4 | import xml.etree.ElementTree as ElementTree 5 | 6 | from clickable.exceptions import ClickableException 7 | from .constants import Constants 8 | from ..utils import ( 9 | find 10 | ) 11 | 12 | class ProjectFiles(object): 13 | def __init__(self, project_dir): 14 | self.project_dir = project_dir 15 | self.desktop = None 16 | 17 | def find_any_desktop(self, temp_dir=None, build_dir=None): 18 | if self.desktop is not None: 19 | return self.desktop 20 | 21 | self.desktop = {} 22 | 23 | desktop_file = find(['.desktop', '.desktop.in', '.desktop.in.in'], 24 | self.project_dir, temp_dir, build_dir, extensions_only=True, depth=3) 25 | 26 | if desktop_file: 27 | with open(desktop_file, 'r') as f: 28 | # Not using configparser here since it has issues with %U that many apps have 29 | for line in f.readlines(): 30 | if '=' in line: 31 | pos = line.find('=') 32 | self.desktop[line[:pos]] = line[(pos + 1):].strip() 33 | 34 | return self.desktop 35 | 36 | def find_any_exec_line(self): 37 | desktop = self.find_any_desktop() 38 | 39 | exec_line = None 40 | if desktop and "Exec" in desktop: 41 | exec_line = desktop["Exec"] 42 | 43 | return exec_line 44 | 45 | def find_any_executable(self): 46 | exec_line = self.find_any_exec_line() 47 | 48 | executable = None 49 | if exec_line: 50 | exec_list = exec_line.split() 51 | 52 | for arg in exec_list: 53 | if "=" not in arg: 54 | executable = arg 55 | break 56 | 57 | return executable 58 | 59 | def find_any_exec_args(self, remove_proc_U=True): 60 | exec_line = self.find_any_exec_line() 61 | executable = self.find_any_executable() 62 | 63 | exec_args = None 64 | if exec_line and executable: 65 | exec_list = exec_line.split() 66 | pos = exec_list.index(executable) 67 | exec_args = exec_list[pos+1:] 68 | 69 | if '%U' in exec_args and remove_proc_U: 70 | exec_args.remove('%U') 71 | 72 | return exec_args 73 | 74 | class InstallFiles(object): 75 | def __init__(self, install_dir, builder, arch): 76 | self.install_dir = install_dir 77 | self.builder = builder 78 | self.arch = arch 79 | 80 | def find_version(self): 81 | if self.builder == Constants.CORDOVA: 82 | tree = ElementTree.parse('config.xml') 83 | root = tree.getroot() 84 | version = root.attrib['version'] if 'version' in root.attrib else '1.0.0' 85 | else: 86 | version = self.get_manifest().get('version', '1.0') 87 | 88 | return version 89 | 90 | def find_package_name(self): 91 | if self.builder == Constants.CORDOVA: 92 | tree = ElementTree.parse('config.xml') 93 | root = tree.getroot() 94 | package = root.attrib['id'] if 'id' in root.attrib else None 95 | 96 | if not package: 97 | raise ClickableException('No package name specified in config.xml') 98 | 99 | else: 100 | package = self.get_manifest().get('name', None) 101 | 102 | if not package: 103 | raise ClickableException('No package name specified in manifest.json or clickable.json') 104 | 105 | return package 106 | 107 | def find_package_title(self): 108 | if self.builder == Constants.CORDOVA: 109 | tree = ElementTree.parse('config.xml') 110 | root = tree.getroot() 111 | title = root.attrib['name'] if 'name' in root.attrib else None 112 | 113 | if not title: 114 | raise ClickableException('No package title specified in config.xml') 115 | 116 | else: 117 | title = self.get_manifest().get('title', None) 118 | 119 | if not title: 120 | raise ClickableException( 121 | 'No package title specified in manifest.json or clickable.json') 122 | 123 | return title 124 | 125 | def find_app_name(self): 126 | app = None 127 | hooks = self.get_manifest().get('hooks', {}) 128 | for key, value in hooks.items(): 129 | if 'desktop' in value: 130 | app = key 131 | break 132 | 133 | if not app: # If we don't find an app with a desktop file just find the first one 134 | apps = list(hooks.keys()) 135 | if len(apps) > 0: 136 | app = apps[0] 137 | 138 | if not app: 139 | raise ClickableException('No app name specified in manifest.json') 140 | 141 | return app 142 | 143 | def find_full_package_name(self): 144 | return '{}_{}_{}'.format( 145 | self.find_package_name(), 146 | self.find_app_name(), 147 | self.find_version()) 148 | 149 | def get_click_filename(self): 150 | return '{}_{}_{}.click'.format(self.find_package_name(), self.find_version(), self.arch) 151 | 152 | def write_manifest(self, manifest): 153 | with open(os.path.join(self.install_dir, "manifest.json"), 'w') as writer: 154 | json.dump(manifest, writer, indent=4) 155 | 156 | 157 | def load_manifest(self, manifest_path): 158 | manifest = {} 159 | with open(manifest_path, 'r') as f: 160 | try: 161 | manifest = json.load(f) 162 | except ValueError: 163 | raise ClickableException( 164 | 'Failed reading "manifest.json", it is not valid json') 165 | 166 | return manifest 167 | 168 | def get_manifest(self): 169 | return self.load_manifest(os.path.join(self.install_dir, "manifest.json")) 170 | 171 | def try_find_locale(self): 172 | return ':'.join(glob.glob("{}/**/locale".format(self.install_dir), recursive=True)) 173 | 174 | def get_desktop(self, cwd, temp_dir=None, build_dir=None): 175 | desktop = {} 176 | 177 | desktop_file = find(['.desktop', '.desktop.in'], cwd, temp_dir, build_dir, extensions_only=True, depth=3) 178 | 179 | if desktop_file: 180 | with open(desktop_file, 'r') as f: 181 | # Not using configparser here since it has issues with %U that many apps have 182 | for line in f.readlines(): 183 | if '=' in line: 184 | pos = line.find('=') 185 | desktop[line[:pos]] = line[(pos + 1):].strip() 186 | 187 | return desktop 188 | -------------------------------------------------------------------------------- /clickable/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import subprocess 3 | import re 4 | import json 5 | import os 6 | import shlex 7 | import glob 8 | import shutil 9 | import inspect 10 | from os.path import dirname, basename, isfile, join 11 | 12 | from clickable.builders.base import Builder 13 | from clickable.logger import logger 14 | from clickable.exceptions import FileNotFoundException, ClickableException 15 | 16 | # TODO use these subprocess functions everywhere 17 | 18 | 19 | def prepare_command(cmd, shell=False): 20 | if isinstance(cmd, str): 21 | if shell: 22 | cmd = cmd.encode() 23 | else: 24 | cmd = shlex.split(cmd) 25 | 26 | if isinstance(cmd, (list, tuple)): 27 | for idx, x in enumerate(cmd): 28 | if isinstance(x, str): 29 | cmd[idx] = x.encode() 30 | 31 | return cmd 32 | 33 | 34 | def run_subprocess_call(cmd, shell=False, **args): 35 | return subprocess.call(prepare_command(cmd, shell), shell=shell, **args) 36 | 37 | 38 | def run_subprocess_check_call(cmd, shell=False, cwd=None, **args): 39 | return subprocess.check_call(prepare_command(cmd, shell), shell=shell, cwd=cwd, **args) 40 | 41 | 42 | def run_subprocess_check_output(cmd, shell=False, **args): 43 | return subprocess.check_output(prepare_command(cmd, shell), shell=shell, **args).decode() 44 | 45 | 46 | def find(names, cwd, temp_dir=None, build_dir=None, ignore_dir=None, extensions_only=False, depth=None): 47 | found = [] 48 | searchpaths = [] 49 | searchpaths.append(cwd) 50 | 51 | include_build_dir = False 52 | if build_dir and not build_dir.startswith(os.path.realpath(cwd) + os.sep): 53 | include_build_dir = True 54 | searchpaths.append(build_dir) 55 | 56 | for (root, dirs, files) in itertools.chain.from_iterable(os.walk(path, topdown=True) for path in searchpaths): 57 | # Ignore hidden directories 58 | new_dirs = [] 59 | for dir in dirs: 60 | if os.path.join(root, dir) == build_dir or not dir[0] == '.': 61 | new_dirs.append(dir) 62 | 63 | dirs[:] = new_dirs 64 | 65 | if depth: 66 | if include_build_dir and root.startswith(build_dir): 67 | if root.count(os.sep) >= (build_dir.count(os.sep) + depth): 68 | del dirs[:] 69 | elif root.startswith(cwd): 70 | if root.count(os.sep) >= (cwd.count(os.sep) + depth): 71 | del dirs[:] 72 | 73 | for name in files: 74 | ok = (name in names) 75 | 76 | if extensions_only: 77 | ok = any([name.endswith(n) for n in names]) 78 | 79 | if ok: 80 | if ignore_dir is not None and root.startswith(ignore_dir): 81 | continue 82 | 83 | found.append(os.path.join(root, name)) 84 | 85 | if not found: 86 | raise FileNotFoundException('Could not find {}'.format(', '.join(names))) 87 | 88 | # Favor the manifest in the install dir first, then fall back to the build dir and finally the source dir 89 | file = '' 90 | for f in found: 91 | if temp_dir and f.startswith(os.path.realpath(temp_dir) + os.sep): 92 | file = f 93 | 94 | if not file: 95 | for f in found: 96 | if build_dir and f.startswith(os.path.realpath(build_dir) + os.sep): 97 | file = f 98 | 99 | if not file: 100 | file = found[0] 101 | 102 | return file 103 | 104 | 105 | def is_command(command): 106 | error_code = run_subprocess_call(shlex.split('which {}'.format(command)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 107 | 108 | return error_code == 0 109 | 110 | 111 | def check_command(command): 112 | if not is_command(command): 113 | raise ClickableException('The command "{}" does not exist on this system, please install it for clickable to work properly"'.format(command)) 114 | 115 | 116 | def env(name): 117 | value = None 118 | if name in os.environ and os.environ[name]: 119 | value = os.environ[name] 120 | 121 | return value 122 | 123 | 124 | def get_builders(): 125 | builder_classes = {} 126 | builder_dir = join(dirname(__file__), 'builders') 127 | modules = glob.glob(join(builder_dir, '*.py')) 128 | builder_modules = [basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')] 129 | 130 | for name in builder_modules: 131 | builder_submodule = __import__('clickable.builders.{}'.format(name), globals(), locals(), [name]) 132 | for name, cls in inspect.getmembers(builder_submodule): 133 | if inspect.isclass(cls) and issubclass(cls, Builder) and cls.name: 134 | builder_classes[cls.name] = cls 135 | 136 | return builder_classes 137 | 138 | 139 | def get_make_jobs_from_args(make_args): 140 | for arg in flexible_string_to_list(make_args): 141 | if arg.startswith('-j'): 142 | jobs_str = arg[2:] 143 | try: 144 | return int(jobs_str) 145 | except ValueError: 146 | raise ClickableException('"{}" in "make_args" is not a number, but it should be.') 147 | 148 | return None 149 | 150 | 151 | def merge_make_jobs_into_args(make_args, make_jobs): 152 | make_jobs_arg = '-j{}'.format(make_jobs) 153 | 154 | if make_args: 155 | return '{} {}'.format(make_args, make_jobs_arg) 156 | else: 157 | return make_jobs_arg 158 | 159 | 160 | def flexible_string_to_list(variable): 161 | if isinstance(variable, (str, bytes)): 162 | return variable.split(' ') 163 | return variable 164 | 165 | 166 | def validate_clickable_json(config, schema): 167 | try: 168 | from jsonschema import validate, ValidationError 169 | try: 170 | validate(instance=config, schema=schema) 171 | except ValidationError as e: 172 | logger.error("The clickable.json configuration file is invalid!") 173 | error_message = e.message 174 | # Lets add the key to the invalid value 175 | if e.path: 176 | if len(e.path) > 1 and isinstance(e.path[-1], int): 177 | error_message = "{} (in '{}')".format(error_message, e.path[-2]) 178 | else: 179 | error_message = "{} (in '{}')".format(error_message, e.path[-1]) 180 | raise ClickableException(error_message) 181 | except ImportError: 182 | logger.warning("Dependency 'jsonschema' not found. Could not validate clickable.json.") 183 | pass 184 | 185 | 186 | def image_exists(image): 187 | command = 'docker image inspect {}'.format(image) 188 | return run_subprocess_call(command, 189 | stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) == 0 190 | 191 | 192 | def makedirs(path): 193 | os.makedirs(path, 0o777, True) 194 | return path 195 | 196 | 197 | def make_absolute(path): 198 | if isinstance(path, list): 199 | return [make_absolute(p) for p in path] 200 | if isinstance(path, dict): 201 | abs_dict = {} 202 | for key in path: 203 | abs_dict[key] = make_absolute(path[key]) 204 | return abs_dict 205 | return os.path.abspath(path) 206 | 207 | 208 | def make_env_var_conform(name): 209 | return re.sub("[^A-Z0-9_]", "_", name.upper()) 210 | 211 | 212 | def is_sub_dir(path, parent): 213 | p1 = os.path.abspath(path) 214 | p2 = os.path.abspath(parent) 215 | return os.path.commonpath([p1, p2]).startswith(p2) 216 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | .. _commands: 2 | 3 | Commands 4 | ======== 5 | 6 | From the root directory of your project you have the following sub-commands available: 7 | 8 | 9 | ``clickable`` 10 | ------------- 11 | 12 | Runs the default sub-commands specified in the "default" config. A dirty build 13 | without cleaning the build dir can be achieved by running 14 | ``clickable --dirty``. 15 | 16 | ``clickable desktop`` 17 | --------------------- 18 | 19 | Compile and run the app on the desktop. 20 | 21 | Note: ArchLinux user might need to run ``xhost +local:clickable`` before using 22 | desktop mode. 23 | 24 | Run ``clickable desktop --verbose`` to show the executed docker command. 25 | 26 | Run ``clickable desktop --dark-mode`` to set the dark mode preference. 27 | 28 | Run ``clickable desktop --lang `` to test using a different language. 29 | 30 | .. _nvidia: 31 | 32 | ``clickable desktop --nvidia`` 33 | ------------------------------ 34 | 35 | ``clickable`` checks automatically if nvidia-drivers are installed and turns on nvidia 36 | mode. If ``prime-select`` is installed, it is queried to check whether the nvidia-driver 37 | is actually in use. 38 | The ``--nvidia`` flag lets you manually enforce nvidia mode. The ``--no-nvidia`` 39 | flag in contrast lets you disable automatic detection. 40 | 41 | Depending on your docker version, the docker execution will change and 42 | you need to provide additional system requirements: 43 | 44 | **docker < 19.03 system requirements** 45 | 46 | * nvidia-modprobe 47 | * nvidia-docker 48 | 49 | On Ubuntu, install these requirements using ``apt install nvidia-modprobe nvidia-docker``. 50 | 51 | **docker >= 19.03 system requirements** 52 | 53 | * nvidia-container-toolkit 54 | 55 | On Ubuntu, install these requirements using ``apt install nvidia-container-toolkit``. 56 | 57 | To be able to install the nvidia-container-toolkit you have to perform the following commands 58 | (as mentioned on https://www.server-world.info/en/note?os=Ubuntu_20.04&p=nvidia&f=2): 59 | 60 | As root: 61 | 62 | .. code-block:: bash 63 | 64 | curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | apt-key add - 65 | 66 | curl -s -L https://nvidia.github.io/nvidia-docker/ubuntu20.04/nvidia-docker.list > /etc/apt/sources.list.d/nvidia-docker.list 67 | 68 | apt update 69 | 70 | apt -y install nvidia-container-toolkit 71 | 72 | systemctl restart docker 73 | 74 | Run clickable with the ``--verbose`` flag to see the executed command for your system. 75 | 76 | .. _commands-ide: 77 | 78 | 79 | ``clickable ide `` 80 | ---------------------------------- 81 | 82 | Will run ``custom_command`` inside ide container wrapper. 83 | e.g. Launch qtcreator: ``clickable ide qtcreator``. 84 | 85 | ``clickable create`` 86 | -------------------- 87 | 88 | Generate a new app from a list of :ref:`app template options `. 89 | 90 | ``clickable create `` 91 | 92 | Generate a new app from an :ref:`app template ` by name. 93 | 94 | ``clickable shell`` 95 | ------------------- 96 | 97 | Opens a shell on the device via ssh. This is similar to the ``phablet-shell`` command. 98 | 99 | ``clickable clean-libs`` 100 | ------------------------ 101 | 102 | Cleans out all library build dirs. 103 | 104 | ``clickable build-libs`` 105 | ------------------------ 106 | 107 | Builds the dependency libraries specified in the clickable.json. 108 | 109 | ``clickable clean`` 110 | ------------------- 111 | 112 | Cleans out the build dir. 113 | 114 | ``clickable build`` 115 | ------------------- 116 | 117 | Builds the project using the specified builder, build dir, and build commands. 118 | Then it takes the built files and compiles them into a click package (you can 119 | find it in the build dir). 120 | 121 | Set the manifest architecture field to ``@CLICK_ARCH@`` to have Clickable replace 122 | it with the appropriate value. 123 | 124 | ``clickable build --output=/path/to/some/diretory`` 125 | --------------------------------------------------- 126 | 127 | Takes the built files and compiles them into a click package, outputting the 128 | compiled click to the directory specified by ``--output``. 129 | 130 | ``clickable clean-build`` 131 | ------------------------- 132 | 133 | Cleans out the build dir before building the project as outlined in the 134 | ``clickable build`` docs. 135 | 136 | ``clickable review`` 137 | -------------------- 138 | 139 | Takes the built click package and runs click-review against it. This allows you 140 | to review your click without installing click-review on your computer. 141 | 142 | .. _commands-test: 143 | 144 | ``clickable test`` 145 | -------------------- 146 | 147 | Run your test suite in with a virtual screen. By default this runs qmltestrunner, 148 | but you can specify a custom command by setting the :ref:`test ` 149 | property in your clickable.json. 150 | 151 | ``clickable install`` 152 | --------------------- 153 | 154 | Takes a built click package and installs it on a device. 155 | 156 | ``clickable install ./path/to/click/app.click`` 157 | 158 | Installs the specified click package on the device 159 | 160 | ``clickable launch`` 161 | -------------------- 162 | 163 | Launches the app on a device. 164 | 165 | ``clickable launch `` 166 | 167 | Launches the specified app on a device. 168 | 169 | ``clickable logs`` 170 | ------------------ 171 | 172 | Follow the apps log file on the device. 173 | 174 | ``clickable log`` 175 | ------------------ 176 | 177 | Dumps the apps log file on the device. 178 | 179 | ``clickable publish`` 180 | --------------------- 181 | 182 | Publish your click app to the OpenStore. Check the 183 | :ref:`Getting started doc ` for more info. 184 | 185 | ``clickable publish "changelog message"`` 186 | 187 | Publish your click app to the OpenStore with a message to add to the changelog. 188 | 189 | ``clickable run "some command"`` 190 | -------------------------------- 191 | 192 | Runs an arbitrary command in the clickable container. Changes do not persist. 193 | This is only meant to inspect the container. Opens a root bash shell if not 194 | command is specified. 195 | 196 | ``clickable update`` 197 | --------------------------- 198 | 199 | Update the docker container for use with clickable. 200 | 201 | ``clickable no-lock`` 202 | --------------------- 203 | 204 | Turns off the device's display timeout. 205 | 206 | ``clickable writable-image`` 207 | ---------------------------- 208 | 209 | Make your Ubuntu Touch device's rootfs writable. This replaces to old 210 | ``phablet-config writable-image`` command. 211 | 212 | ``clickable devices`` 213 | --------------------- 214 | 215 | Lists the serial numbers and model names for attached devices. Useful when 216 | multiple devices are attached and you need to know what to use for the ``-s`` 217 | argument. 218 | 219 | ``clickable `` 220 | ------------------------------ 221 | 222 | Runs a custom command specified in the "scripts" config 223 | 224 | .. _container-mode: 225 | 226 | ``clickable --container-mode`` 227 | -------------------------------------------- 228 | 229 | Runs all builds commands on the current machine and not in a container. This is 230 | useful from running clickable from within a container. 231 | 232 | ``clickable --verbose`` 233 | ------------------------------------- 234 | 235 | Have Clickable print out debug information about whatever command(s) are being run. 236 | 237 | ``clickable --ssh `` 238 | ---------------------------------------------- 239 | 240 | Run a command with a device over ssh rather than the default adb. 241 | --------------------------------------------------------------------------------