fbt_extapps.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import itertools
  2. import os
  3. import pathlib
  4. import shutil
  5. from dataclasses import dataclass, field
  6. from typing import Optional, TypedDict
  7. from ansi.color import fg
  8. import SCons.Warnings
  9. from SCons.Action import Action
  10. from SCons.Builder import Builder
  11. from SCons.Errors import UserError
  12. from SCons.Node import NodeList
  13. from SCons.Node.FS import File, Entry
  14. from fbt.appmanifest import FlipperApplication, FlipperAppType, FlipperManifestException
  15. from fbt.elfmanifest import assemble_manifest_data
  16. from fbt.fapassets import FileBundler
  17. from fbt.sdk.cache import SdkCache
  18. from fbt.util import extract_abs_dir_path
  19. @dataclass
  20. class FlipperExternalAppInfo:
  21. app: FlipperApplication
  22. compact: Optional[File] = None
  23. debug: Optional[File] = None
  24. validator: Optional[Entry] = None
  25. # List of tuples (dist_to_sd, path)
  26. dist_entries: list[tuple[bool, str]] = field(default_factory=list)
  27. class AppBuilder:
  28. def __init__(self, env, app):
  29. self.fw_env = env
  30. self.app = app
  31. self.ext_apps_work_dir = env.subst("$EXT_APPS_WORK_DIR")
  32. self.app_work_dir = os.path.join(self.ext_apps_work_dir, self.app.appid)
  33. self.app_alias = f"fap_{self.app.appid}"
  34. self.externally_built_files = []
  35. self.private_libs = []
  36. def build(self):
  37. self._setup_app_env()
  38. self._build_external_files()
  39. self._compile_assets()
  40. self._build_private_libs()
  41. return self._build_app()
  42. def _setup_app_env(self):
  43. self.app_env = self.fw_env.Clone(
  44. FAP_SRC_DIR=self.app._appdir, FAP_WORK_DIR=self.app_work_dir
  45. )
  46. self.app_env.VariantDir(self.app_work_dir, self.app._appdir, duplicate=False)
  47. def _build_external_files(self):
  48. if not self.app.fap_extbuild:
  49. return
  50. for external_file_def in self.app.fap_extbuild:
  51. self.externally_built_files.append(external_file_def.path)
  52. self.app_env.Alias(self.app_alias, external_file_def.path)
  53. self.app_env.AlwaysBuild(
  54. self.app_env.Command(
  55. external_file_def.path,
  56. None,
  57. Action(
  58. external_file_def.command,
  59. "" if self.app_env["VERBOSE"] else "\tEXTCMD\t${TARGET}",
  60. ),
  61. )
  62. )
  63. def _compile_assets(self):
  64. if not self.app.fap_icon_assets:
  65. return
  66. fap_icons = self.app_env.CompileIcons(
  67. self.app_env.Dir(self.app_work_dir),
  68. self.app._appdir.Dir(self.app.fap_icon_assets),
  69. icon_bundle_name=f"{self.app.fap_icon_assets_symbol if self.app.fap_icon_assets_symbol else self.app.appid }_icons",
  70. )
  71. self.app_env.Alias("_fap_icons", fap_icons)
  72. self.fw_env.Append(_APP_ICONS=[fap_icons])
  73. def _build_private_libs(self):
  74. for lib_def in self.app.fap_private_libs:
  75. self.private_libs.append(self._build_private_lib(lib_def))
  76. def _build_private_lib(self, lib_def):
  77. lib_src_root_path = os.path.join(self.app_work_dir, "lib", lib_def.name)
  78. self.app_env.AppendUnique(
  79. CPPPATH=list(
  80. self.app_env.Dir(lib_src_root_path)
  81. .Dir(incpath)
  82. .srcnode()
  83. .rfile()
  84. .abspath
  85. for incpath in lib_def.fap_include_paths
  86. ),
  87. )
  88. lib_sources = list(
  89. itertools.chain.from_iterable(
  90. self.app_env.GlobRecursive(source_type, lib_src_root_path)
  91. for source_type in lib_def.sources
  92. )
  93. )
  94. if len(lib_sources) == 0:
  95. raise UserError(f"No sources gathered for private library {lib_def}")
  96. private_lib_env = self.app_env.Clone()
  97. private_lib_env.AppendUnique(
  98. CCFLAGS=[
  99. *lib_def.cflags,
  100. ],
  101. CPPDEFINES=lib_def.cdefines,
  102. CPPPATH=list(
  103. map(
  104. lambda cpath: extract_abs_dir_path(self.app._appdir.Dir(cpath)),
  105. lib_def.cincludes,
  106. )
  107. ),
  108. )
  109. return private_lib_env.StaticLibrary(
  110. os.path.join(self.app_work_dir, lib_def.name),
  111. lib_sources,
  112. )
  113. def _build_app(self):
  114. self.app_env.Append(
  115. LIBS=[*self.app.fap_libs, *self.private_libs],
  116. CPPPATH=self.app_env.Dir(self.app_work_dir),
  117. )
  118. app_sources = list(
  119. itertools.chain.from_iterable(
  120. self.app_env.GlobRecursive(
  121. source_type,
  122. self.app_work_dir,
  123. exclude="lib",
  124. )
  125. for source_type in self.app.sources
  126. )
  127. )
  128. app_artifacts = FlipperExternalAppInfo(self.app)
  129. app_artifacts.debug = self.app_env.Program(
  130. os.path.join(self.ext_apps_work_dir, f"{self.app.appid}_d"),
  131. app_sources,
  132. APP_ENTRY=self.app.entry_point,
  133. )[0]
  134. app_artifacts.compact = self.app_env.EmbedAppMetadata(
  135. os.path.join(self.ext_apps_work_dir, self.app.appid),
  136. app_artifacts.debug,
  137. APP=self.app,
  138. )[0]
  139. app_artifacts.validator = self.app_env.ValidateAppImports(
  140. app_artifacts.compact
  141. )[0]
  142. if self.app.apptype == FlipperAppType.PLUGIN:
  143. for parent_app_id in self.app.requires:
  144. fal_path = (
  145. f"apps_data/{parent_app_id}/plugins/{app_artifacts.compact.name}"
  146. )
  147. deployable = True
  148. # If it's a plugin for a non-deployable app, don't include it in the resources
  149. if parent_app := self.app._appmanager.get(parent_app_id):
  150. if not parent_app.is_default_deployable:
  151. deployable = False
  152. app_artifacts.dist_entries.append((deployable, fal_path))
  153. else:
  154. fap_path = f"apps/{self.app.fap_category}/{app_artifacts.compact.name}"
  155. app_artifacts.dist_entries.append(
  156. (self.app.is_default_deployable, fap_path)
  157. )
  158. self._configure_deps_and_aliases(app_artifacts)
  159. return app_artifacts
  160. def _configure_deps_and_aliases(self, app_artifacts: FlipperExternalAppInfo):
  161. # Extra things to clean up along with the app
  162. self.app_env.Clean(
  163. app_artifacts.debug,
  164. [*self.externally_built_files, self.app_env.Dir(self.app_work_dir)],
  165. )
  166. # Create listing of the app
  167. app_elf_dump = self.app_env.ObjDump(app_artifacts.debug)
  168. self.app_env.Alias(f"{self.app_alias}_list", app_elf_dump)
  169. # Extra dependencies for the app - manifest values, icon file
  170. manifest_vals = {
  171. k: v
  172. for k, v in vars(self.app).items()
  173. if not k.startswith(FlipperApplication.PRIVATE_FIELD_PREFIX)
  174. }
  175. self.app_env.Depends(
  176. app_artifacts.compact,
  177. [self.app_env["SDK_DEFINITION"], self.app_env.Value(manifest_vals)],
  178. )
  179. if self.app.fap_icon:
  180. self.app_env.Depends(
  181. app_artifacts.compact,
  182. self.app_env.File(f"{self.app._apppath}/{self.app.fap_icon}"),
  183. )
  184. # Add dependencies on file assets
  185. if self.app.fap_file_assets:
  186. self.app_env.Depends(
  187. app_artifacts.compact,
  188. self.app_env.GlobRecursive(
  189. "*",
  190. self.app._appdir.Dir(self.app.fap_file_assets),
  191. ),
  192. )
  193. # Always run the validator for the app's binary when building the app
  194. self.app_env.AlwaysBuild(app_artifacts.validator)
  195. self.app_env.Alias(self.app_alias, app_artifacts.validator)
  196. def BuildAppElf(env, app):
  197. app_builder = AppBuilder(env, app)
  198. env["EXT_APPS"][app.appid] = app_artifacts = app_builder.build()
  199. return app_artifacts
  200. def prepare_app_metadata(target, source, env):
  201. sdk_cache = SdkCache(env["SDK_DEFINITION"].path, load_version_only=True)
  202. if not sdk_cache.is_buildable():
  203. raise UserError(
  204. "SDK version is not finalized, please review changes and re-run operation. See AppsOnSDCard.md for more details."
  205. )
  206. app = env["APP"]
  207. meta_file_name = source[0].path + ".meta"
  208. with open(meta_file_name, "wb") as f:
  209. f.write(
  210. assemble_manifest_data(
  211. app_manifest=app,
  212. hardware_target=int(env.subst("$TARGET_HW")),
  213. sdk_version=sdk_cache.version.as_int(),
  214. )
  215. )
  216. def validate_app_imports(target, source, env):
  217. sdk_cache = SdkCache(env["SDK_DEFINITION"].path, load_version_only=False)
  218. app_syms = set()
  219. with open(target[0].path, "rt") as f:
  220. for line in f:
  221. app_syms.add(line.split()[0])
  222. unresolved_syms = app_syms - sdk_cache.get_valid_names()
  223. if unresolved_syms:
  224. warning_msg = fg.brightyellow(
  225. f"{source[0].path}: app may not be runnable. Symbols not resolved using firmware's API: "
  226. ) + fg.brightmagenta(f"{unresolved_syms}")
  227. disabled_api_syms = unresolved_syms.intersection(sdk_cache.get_disabled_names())
  228. if disabled_api_syms:
  229. warning_msg += (
  230. fg.brightyellow(" (in API, but disabled: ")
  231. + fg.brightmagenta(f"{disabled_api_syms}")
  232. + fg.brightyellow(")")
  233. )
  234. SCons.Warnings.warn(SCons.Warnings.LinkWarning, warning_msg),
  235. def GetExtAppByIdOrPath(env, app_dir):
  236. if not app_dir:
  237. raise UserError("APPSRC= not set")
  238. appmgr = env["APPMGR"]
  239. app = None
  240. try:
  241. # Maybe user passed an appid?
  242. app = appmgr.get(app_dir)
  243. except FlipperManifestException as _:
  244. # Look up path components in known app dirs
  245. for dir_part in reversed(pathlib.Path(app_dir).parts):
  246. if app := appmgr.find_by_appdir(dir_part):
  247. break
  248. if not app:
  249. raise UserError(f"Failed to resolve application for given APPSRC={app_dir}")
  250. app_artifacts = env["EXT_APPS"].get(app.appid, None)
  251. if not app_artifacts:
  252. raise UserError(
  253. f"Application {app.appid} is not configured to be built as external"
  254. )
  255. return app_artifacts
  256. def resources_fap_dist_emitter(target, source, env):
  257. # Initially we have a single target - target dir
  258. # Here we inject pairs of (target, source) for each file
  259. resources_root = target[0]
  260. target = []
  261. for app_artifacts in env["EXT_APPS"].values():
  262. for _, dist_path in filter(
  263. lambda dist_entry: dist_entry[0], app_artifacts.dist_entries
  264. ):
  265. source.append(app_artifacts.compact)
  266. target.append(resources_root.File(dist_path))
  267. assert len(target) == len(source)
  268. return (target, source)
  269. def resources_fap_dist_action(target, source, env):
  270. # FIXME: find a proper way to remove stale files
  271. target_dir = env.Dir("${RESOURCES_ROOT}/apps")
  272. shutil.rmtree(target_dir.path, ignore_errors=True)
  273. # Iterate over pairs generated in emitter
  274. for src, target in zip(source, target):
  275. os.makedirs(os.path.dirname(target.path), exist_ok=True)
  276. shutil.copy(src.path, target.path)
  277. def embed_app_metadata_emitter(target, source, env):
  278. app = env["APP"]
  279. # Hack: change extension for fap libs
  280. if app.apptype == FlipperAppType.PLUGIN:
  281. target[0].name = target[0].name.replace(".fap", ".fal")
  282. meta_file_name = source[0].path + ".meta"
  283. target.append("#" + meta_file_name)
  284. if app.fap_file_assets:
  285. files_section = source[0].path + ".files.section"
  286. target.append("#" + files_section)
  287. return (target, source)
  288. def prepare_app_files(target, source, env):
  289. app = env["APP"]
  290. directory = app._appdir.Dir(app.fap_file_assets)
  291. if not directory.exists():
  292. raise UserError(f"File asset directory {directory} does not exist")
  293. bundler = FileBundler(directory.abspath)
  294. bundler.export(source[0].path + ".files.section")
  295. def generate_embed_app_metadata_actions(source, target, env, for_signature):
  296. app = env["APP"]
  297. actions = [
  298. Action(prepare_app_metadata, "$APPMETA_COMSTR"),
  299. ]
  300. objcopy_str = (
  301. "${OBJCOPY} "
  302. "--remove-section .ARM.attributes "
  303. "--add-section .fapmeta=${SOURCE}.meta "
  304. )
  305. if app.fap_file_assets:
  306. actions.append(Action(prepare_app_files, "$APPFILE_COMSTR"))
  307. objcopy_str += "--add-section .fapassets=${SOURCE}.files.section "
  308. objcopy_str += (
  309. "--set-section-flags .fapmeta=contents,noload,readonly,data "
  310. "--strip-debug --strip-unneeded "
  311. "--add-gnu-debuglink=${SOURCE} "
  312. "${SOURCES} ${TARGET}"
  313. )
  314. actions.append(
  315. Action(
  316. objcopy_str,
  317. "$APPMETAEMBED_COMSTR",
  318. )
  319. )
  320. return Action(actions)
  321. def generate(env, **kw):
  322. env.SetDefault(
  323. EXT_APPS_WORK_DIR="${FBT_FAP_DEBUG_ELF_ROOT}",
  324. APP_RUN_SCRIPT="${FBT_SCRIPT_DIR}/runfap.py",
  325. STORAGE_SCRIPT="${FBT_SCRIPT_DIR}/storage.py",
  326. )
  327. if not env["VERBOSE"]:
  328. env.SetDefault(
  329. FAPDISTCOMSTR="\tFAPDIST\t${TARGET}",
  330. APPMETA_COMSTR="\tAPPMETA\t${TARGET}",
  331. APPFILE_COMSTR="\tAPPFILE\t${TARGET}",
  332. APPMETAEMBED_COMSTR="\tFAP\t${TARGET}",
  333. APPCHECK_COMSTR="\tAPPCHK\t${SOURCE}",
  334. )
  335. env.SetDefault(
  336. EXT_APPS={}, # appid -> FlipperExternalAppInfo
  337. EXT_LIBS={},
  338. _APP_ICONS=[],
  339. )
  340. env.AddMethod(BuildAppElf)
  341. env.AddMethod(GetExtAppByIdOrPath)
  342. env.Append(
  343. BUILDERS={
  344. "FapDist": Builder(
  345. action=Action(
  346. resources_fap_dist_action,
  347. "$FAPDISTCOMSTR",
  348. ),
  349. emitter=resources_fap_dist_emitter,
  350. ),
  351. "EmbedAppMetadata": Builder(
  352. generator=generate_embed_app_metadata_actions,
  353. suffix=".fap",
  354. src_suffix=".elf",
  355. emitter=embed_app_metadata_emitter,
  356. ),
  357. "ValidateAppImports": Builder(
  358. action=[
  359. Action(
  360. "@${NM} -P -u ${SOURCE} > ${TARGET}",
  361. None, # "$APPDUMP_COMSTR",
  362. ),
  363. Action(
  364. validate_app_imports,
  365. "$APPCHECK_COMSTR",
  366. ),
  367. ],
  368. suffix=".impsyms",
  369. src_suffix=".fap",
  370. ),
  371. }
  372. )
  373. def exists(env):
  374. return True