unleashed-firmware/scripts/fbt/appmanifest.py

475 lines
16 KiB
Python

import os
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, ClassVar, List, Optional, Tuple, Union
try:
from fbt.util import resolve_real_dir_node
except ImportError:
# When running outside of SCons, we don't have access to SCons.Node
def resolve_real_dir_node(node):
return node
class FlipperManifestException(Exception):
pass
class FlipperAppType(Enum):
SERVICE = "Service"
SYSTEM = "System"
APP = "App"
DEBUG = "Debug"
ARCHIVE = "Archive"
SETTINGS = "Settings"
STARTUP = "StartupHook"
EXTERNAL = "External"
MENUEXTERNAL = "MenuExternal"
METAPACKAGE = "Package"
PLUGIN = "Plugin"
@dataclass
class FlipperApplication:
APP_ID_REGEX: ClassVar[re.Pattern] = re.compile(r"^[a-z0-9_]+$")
PRIVATE_FIELD_PREFIX: ClassVar[str] = "_"
APP_MANIFEST_DEFAULT_NAME: ClassVar[str] = "application.fam"
@dataclass
class ExternallyBuiltFile:
path: str
command: str
@dataclass
class Library:
name: str
fap_include_paths: List[str] = field(default_factory=lambda: ["."])
sources: List[str] = field(default_factory=lambda: ["*.c*"])
cflags: List[str] = field(default_factory=list)
cdefines: List[str] = field(default_factory=list)
cincludes: List[str] = field(default_factory=list)
appid: str
apptype: FlipperAppType
name: Optional[str] = ""
entry_point: Optional[str] = None
flags: List[str] = field(default_factory=lambda: ["Default"])
cdefines: List[str] = field(default_factory=list)
requires: List[str] = field(default_factory=list)
conflicts: List[str] = field(default_factory=list)
provides: List[str] = field(default_factory=list)
stack_size: int = 2048
icon: Optional[str] = None
order: int = 0
sdk_headers: List[str] = field(default_factory=list)
targets: List[str] = field(default_factory=lambda: ["all"])
resources: Optional[str] = None
# .fap-specific
sources: List[str] = field(default_factory=lambda: ["*.c*"])
fap_version: Union[str, Tuple[int]] = "0.1"
fap_icon: Optional[str] = None
fap_libs: List[str] = field(default_factory=list)
fap_category: str = ""
fap_description: str = ""
fap_author: str = ""
fap_weburl: str = ""
fap_icon_assets: Optional[str] = None
fap_icon_assets_symbol: Optional[str] = None
fap_extbuild: List[ExternallyBuiltFile] = field(default_factory=list)
fap_private_libs: List[Library] = field(default_factory=list)
fap_file_assets: Optional[str] = None
fal_embedded: bool = False
# Internally used by fbt
_appmanager: Optional["AppManager"] = None
_appdir: Optional[object] = None
_apppath: Optional[str] = None
_plugins: List["FlipperApplication"] = field(default_factory=list)
_assets_dirs: List[object] = field(default_factory=list)
_section_fapmeta: Optional[object] = None
_section_fapfileassets: Optional[object] = None
@property
def embeds_plugins(self):
return any(plugin.fal_embedded for plugin in self._plugins)
def supports_hardware_target(self, target: str):
return target in self.targets or "all" in self.targets
@property
def is_default_deployable(self):
return self.apptype != FlipperAppType.DEBUG and self.fap_category != "Examples"
@property
def do_strict_import_checks(self):
return self.apptype != FlipperAppType.PLUGIN
def __post_init__(self):
if self.apptype == FlipperAppType.PLUGIN:
self.stack_size = 0
if not self.APP_ID_REGEX.match(self.appid):
raise FlipperManifestException(
f"Invalid appid '{self.appid}'. Must match regex '{self.APP_ID_REGEX}'"
)
if isinstance(self.fap_version, str):
try:
self.fap_version = tuple(int(v) for v in self.fap_version.split("."))
except ValueError:
raise FlipperManifestException(
f"Invalid version '{self.fap_version}'. Must be in the form 'major.minor'"
)
if len(self.fap_version) < 2:
raise ValueError("Not enough version components")
class AppManager:
def __init__(self):
self.known_apps = {}
def get(self, appname: str):
try:
return self.known_apps[appname]
except KeyError:
raise FlipperManifestException(
f"Missing application manifest for '{appname}'"
)
def find_by_appdir(self, appdir: str):
for app in self.known_apps.values():
if app._appdir.name == appdir:
return app
return None
def _validate_app_params(self, *args, **kw):
apptype = kw.get("apptype")
if apptype == FlipperAppType.PLUGIN:
if kw.get("stack_size"):
raise FlipperManifestException(
f"Plugin {kw.get('appid')} cannot have stack (did you mean FlipperAppType.EXTERNAL?)"
)
if not kw.get("requires"):
raise FlipperManifestException(
f"Plugin {kw.get('appid')} must have 'requires' in manifest"
)
else:
if kw.get("fal_embedded"):
raise FlipperManifestException(
f"App {kw.get('appid')} cannot have fal_embedded set"
)
if apptype in AppBuildset.dist_app_types:
# For distributing .fap's resources, there's "fap_file_assets"
for app_property in ("resources",):
if kw.get(app_property):
raise FlipperManifestException(
f"App {kw.get('appid')} of type {apptype} cannot have '{app_property}' in manifest"
)
else:
for app_property in (
"fap_extbuild",
"fap_private_libs",
): # , "fap_icon_assets"): TODO: Find a workaround for subghz_remote app
if kw.get(app_property):
raise FlipperManifestException(
f"App {kw.get('appid')} of type {apptype} must not have '{app_property}' in manifest"
)
def load_manifest(self, app_manifest_path: str, app_dir_node: object):
if not os.path.exists(app_manifest_path):
raise FlipperManifestException(
f"App manifest not found at path {app_manifest_path}"
)
# print("Loading", app_manifest_path)
app_manifests = []
def App(*args, **kw):
nonlocal app_manifests
self._validate_app_params(*args, **kw)
app_manifests.append(
FlipperApplication(
*args,
**kw,
_appdir=resolve_real_dir_node(app_dir_node),
_apppath=os.path.dirname(app_manifest_path),
_appmanager=self,
),
)
def ExtFile(*args, **kw):
return FlipperApplication.ExternallyBuiltFile(*args, **kw)
def Lib(*args, **kw):
return FlipperApplication.Library(*args, **kw)
try:
with open(app_manifest_path, "rt") as manifest_file:
exec(manifest_file.read())
except Exception as e:
raise FlipperManifestException(
f"Failed parsing manifest '{app_manifest_path}' : {e}"
)
if len(app_manifests) == 0:
raise FlipperManifestException(
f"App manifest '{app_manifest_path}' is malformed"
)
# print("Built", app_manifests)
for app in app_manifests:
self._add_known_app(app)
def _add_known_app(self, app: FlipperApplication):
if self.known_apps.get(app.appid, None):
raise FlipperManifestException(f"Duplicate app declaration: {app.appid}")
self.known_apps[app.appid] = app
def filter_apps(
self,
*,
applist: List[str],
ext_applist: List[str],
hw_target: str,
):
return AppBuildset(
self,
hw_target=hw_target,
appnames=applist,
extra_ext_appnames=ext_applist,
)
class AppBuilderException(Exception):
pass
class AppBuildset:
BUILTIN_APP_TYPES = (
FlipperAppType.SERVICE,
FlipperAppType.SYSTEM,
FlipperAppType.APP,
FlipperAppType.DEBUG,
FlipperAppType.ARCHIVE,
FlipperAppType.SETTINGS,
FlipperAppType.STARTUP,
)
EXTERNAL_APP_TYPES_MAP = {
# AppType -> bool: true if always deploy, false if obey app set
FlipperAppType.EXTERNAL: True,
FlipperAppType.PLUGIN: True,
FlipperAppType.DEBUG: True,
FlipperAppType.MENUEXTERNAL: False,
}
@classmethod
@property
def dist_app_types(cls):
"""Applications that are installed on SD card"""
return list(
entry[0] for entry in cls.EXTERNAL_APP_TYPES_MAP.items() if entry[1]
)
@staticmethod
def print_writer(message):
print(message)
def __init__(
self,
appmgr: AppManager,
hw_target: str,
appnames: List[str],
*,
extra_ext_appnames: List[str],
message_writer: Callable | None = None,
):
self.appmgr = appmgr
self.appnames = set(appnames)
self.incompatible_extapps, self.extapps = [], []
self._extra_ext_appnames = extra_ext_appnames
self._orig_appnames = appnames
self.hw_target = hw_target
self._writer = message_writer if message_writer else self.print_writer
self._process_deps()
self._process_ext_apps()
self._check_conflicts()
self._check_unsatisfied() # unneeded?
self._check_target_match()
self._group_plugins()
self._apps = sorted(
list(map(self.appmgr.get, self.appnames)),
key=lambda app: app.appid,
)
@property
def apps(self):
return list(self._apps)
def _is_missing_dep(self, dep_name: str):
return dep_name not in self.appnames
def _check_if_app_target_supported(self, app_name: str):
return self.appmgr.get(app_name).supports_hardware_target(self.hw_target)
def _get_app_depends(self, app_name: str) -> List[str]:
app_def = self.appmgr.get(app_name)
# Skip app if its target is not supported by the target we are building for
if not self._check_if_app_target_supported(app_name):
self._writer(
f"Skipping {app_name} due to target mismatch (building for {self.hw_target}, app supports {app_def.targets}"
)
return []
return list(
filter(
self._check_if_app_target_supported,
filter(self._is_missing_dep, app_def.provides + app_def.requires),
)
)
def _process_deps(self):
while True:
provided = []
for app_name in self.appnames:
provided.extend(self._get_app_depends(app_name))
# print("provides round: ", provided)
if len(provided) == 0:
break
self.appnames.update(provided)
def _process_ext_apps(self):
extapps = [
app
for (apptype, global_lookup) in self.EXTERNAL_APP_TYPES_MAP.items()
for app in self.get_apps_of_type(apptype, global_lookup)
]
extapps.extend(map(self.appmgr.get, self._extra_ext_appnames))
for app in extapps:
(
self.extapps
if app.supports_hardware_target(self.hw_target)
else self.incompatible_extapps
).append(app)
def get_ext_apps(self):
return list(self.extapps)
def get_incompatible_ext_apps(self):
return list(self.incompatible_extapps)
def _check_conflicts(self):
conflicts = []
for app in self.appnames:
if conflict_app_name := list(
filter(
lambda dep_name: dep_name in self.appnames,
self.appmgr.get(app).conflicts,
)
):
conflicts.append((app, conflict_app_name))
if len(conflicts):
raise AppBuilderException(
f"App conflicts for {', '.join(f'{conflict_dep[0]}: {conflict_dep[1]}' for conflict_dep in conflicts)}"
)
def _check_unsatisfied(self):
unsatisfied = []
for app in self.appnames:
if missing_dep := list(
filter(self._is_missing_dep, self.appmgr.get(app).requires)
):
unsatisfied.append((app, missing_dep))
if len(unsatisfied):
raise AppBuilderException(
f"Unsatisfied dependencies for {', '.join(f'{missing_dep[0]}: {missing_dep[1]}' for missing_dep in unsatisfied)}"
)
def _check_target_match(self):
incompatible = []
for app in self.appnames:
if not self.appmgr.get(app).supports_hardware_target(self.hw_target):
incompatible.append(app)
if len(incompatible):
raise AppBuilderException(
f"Apps incompatible with target {self.hw_target}: {', '.join(incompatible)}"
)
def _group_plugins(self):
known_extensions = self.get_apps_of_type(FlipperAppType.PLUGIN, all_known=True)
for extension_app in known_extensions:
keep_app = False
for parent_app_id in extension_app.requires:
try:
parent_app = self.appmgr.get(parent_app_id)
parent_app._plugins.append(extension_app)
if (
parent_app.apptype in self.BUILTIN_APP_TYPES
and parent_app_id in self.appnames
) or parent_app.apptype not in self.BUILTIN_APP_TYPES:
keep_app |= True
except FlipperManifestException:
self._writer(
f"Module {extension_app.appid} has unknown parent {parent_app_id}"
)
keep_app = True
# Debug output for plugin parentage
# print(
# f"Module {extension_app.appid} has parents {extension_app.requires} keep={keep_app}"
# )
if not keep_app and extension_app in self.extapps:
# print(f"Excluding plugin {extension_app.appid}")
self.extapps.remove(extension_app)
def get_apps_cdefs(self):
cdefs = set()
for app in self._apps:
cdefs.update(app.cdefines)
return sorted(list(cdefs))
def get_sdk_headers(self):
sdk_headers = []
for app in self._apps:
sdk_headers.extend(
[
src._appdir.File(header)
for src in [app, *app._plugins]
for header in src.sdk_headers
]
)
return sdk_headers
def get_apps_of_type(self, apptype: FlipperAppType, all_known: bool = False):
"""Looks up apps of given type in current app set. If all_known is true,
ignores app set and checks all loaded apps' manifests."""
return sorted(
filter(
lambda app: app.apptype == apptype,
(
self.appmgr.known_apps.values()
if all_known
else map(self.appmgr.get, self.appnames)
),
),
key=lambda app: app.order,
)
def get_builtin_apps(self):
return list(
filter(lambda app: app.apptype in self.BUILTIN_APP_TYPES, self._apps)
)
def get_builtin_app_folders(self):
return sorted(
set(
(app._appdir, source_type)
for app in self.get_builtin_apps()
for source_type in app.sources
)
)