├── 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 |
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 |
--------------------------------------------------------------------------------