fbt_extapps.py 17 KB

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