mirror of
https://github.com/DarkFlippers/unleashed-firmware
synced 2024-11-26 22:40:25 +00:00
475 lines
16 KiB
Python
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
|
|
)
|
|
)
|