Procházet zdrojové kódy

fbt: fixes for ufbt pt3 (#1970)

* fbt: replaced debug dir paths with FBT_DEBUG_DIR
* scripts: updated requirements.txt
* fbt: fixed wrong import
* fbt: removed delayed import for file2image
* fbt: added UPDATE_BUNDLE_DIR internal var
* fbt: cleaner internal management of extapps
* applications: added fap_libs for core apps to link with resources when building with --extra-ext-apps
* fbt: removed deprecation stub for faps
* fbt: added quotation for icons build cmd
* fbt: reworked BUILD_DIR & fap work dir handling; fap debug: using debug elf path from fbt
* fbt: explicit LIB_DIST_DIR
hedger před 3 roky
rodič
revize
04e50c9f89

+ 20 - 10
SConstruct

@@ -43,6 +43,7 @@ distenv = coreenv.Clone(
         "jflash",
     ],
     ENV=os.environ,
+    UPDATE_BUNDLE_DIR="dist/${DIST_DIR}/f${TARGET_HW}-update-${DIST_SUFFIX}",
 )
 
 firmware_env = distenv.AddFwProject(
@@ -140,21 +141,28 @@ distenv.Default(basic_dist)
 dist_dir = distenv.GetProjetDirName()
 fap_dist = [
     distenv.Install(
-        f"#/dist/{dist_dir}/apps/debug_elf",
-        firmware_env["FW_EXTAPPS"]["debug"].values(),
+        distenv.Dir(f"#/dist/{dist_dir}/apps/debug_elf"),
+        list(
+            app_artifact.debug
+            for app_artifact in firmware_env["FW_EXTAPPS"].applications.values()
+        ),
     ),
-    *(
-        distenv.Install(f"#/dist/{dist_dir}/apps/{dist_entry[0]}", dist_entry[1])
-        for dist_entry in firmware_env["FW_EXTAPPS"]["dist"].values()
+    distenv.Install(
+        f"#/dist/{dist_dir}/apps",
+        "#/assets/resources/apps",
     ),
 ]
-Depends(fap_dist, firmware_env["FW_EXTAPPS"]["validators"].values())
+Depends(
+    fap_dist,
+    list(
+        app_artifact.validator
+        for app_artifact in firmware_env["FW_EXTAPPS"].applications.values()
+    ),
+)
 Alias("fap_dist", fap_dist)
 # distenv.Default(fap_dist)
 
-distenv.Depends(
-    firmware_env["FW_RESOURCES"], firmware_env["FW_EXTAPPS"]["resources_dist"]
-)
+distenv.Depends(firmware_env["FW_RESOURCES"], firmware_env["FW_EXTAPPS"].resources_dist)
 
 
 # Target for bundling core2 package for qFlipper
@@ -192,6 +200,7 @@ firmware_debug = distenv.PhonyTarget(
     source=firmware_env["FW_ELF"],
     GDBOPTS="${GDBOPTS_BASE}",
     GDBREMOTE="${OPENOCD_GDB_PIPE}",
+    FBT_FAP_DEBUG_ELF_ROOT=firmware_env.subst("$FBT_FAP_DEBUG_ELF_ROOT"),
 )
 distenv.Depends(firmware_debug, firmware_flash)
 
@@ -201,6 +210,7 @@ distenv.PhonyTarget(
     source=firmware_env["FW_ELF"],
     GDBOPTS="${GDBOPTS_BASE} ${GDBOPTS_BLACKMAGIC}",
     GDBREMOTE="${BLACKMAGIC_ADDR}",
+    FBT_FAP_DEBUG_ELF_ROOT=firmware_env.subst("$FBT_FAP_DEBUG_ELF_ROOT"),
 )
 
 # Debug alien elf
@@ -209,7 +219,7 @@ distenv.PhonyTarget(
     "${GDBPYCOM}",
     GDBOPTS="${GDBOPTS_BASE}",
     GDBREMOTE="${OPENOCD_GDB_PIPE}",
-    GDBPYOPTS='-ex "source debug/PyCortexMDebug/PyCortexMDebug.py" ',
+    GDBPYOPTS='-ex "source ${FBT_DEBUG_DIR}/PyCortexMDebug/PyCortexMDebug.py" ',
 )
 
 distenv.PhonyTarget(

+ 1 - 0
applications/main/bad_usb/application.fam

@@ -11,4 +11,5 @@ App(
     stack_size=2 * 1024,
     icon="A_BadUsb_14",
     order=70,
+    fap_libs=["assets"],
 )

+ 1 - 0
applications/main/gpio/application.fam

@@ -8,4 +8,5 @@ App(
     stack_size=1 * 1024,
     icon="A_GPIO_14",
     order=50,
+    fap_libs=["assets"],
 )

+ 1 - 0
applications/main/ibutton/application.fam

@@ -12,6 +12,7 @@ App(
     icon="A_iButton_14",
     stack_size=2 * 1024,
     order=60,
+    fap_libs=["assets"],
 )
 
 App(

+ 1 - 0
applications/main/infrared/application.fam

@@ -12,6 +12,7 @@ App(
     icon="A_Infrared_14",
     stack_size=3 * 1024,
     order=40,
+    fap_libs=["assets"],
 )
 
 App(

+ 1 - 0
applications/main/lfrfid/application.fam

@@ -14,6 +14,7 @@ App(
     icon="A_125khz_14",
     stack_size=2 * 1024,
     order=20,
+    fap_libs=["assets"],
 )
 
 App(

+ 1 - 0
applications/main/u2f/application.fam

@@ -11,4 +11,5 @@ App(
     stack_size=2 * 1024,
     icon="A_U2F_14",
     order=80,
+    fap_libs=["assets"],
 )

+ 35 - 11
debug/flipperapps.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import Tuple, Dict
+from typing import Optional, Tuple, Dict, ClassVar
 import struct
 import posixpath
 import os
@@ -22,14 +22,18 @@ class AppState:
     debug_link_elf: str = ""
     debug_link_crc: int = 0
 
+    DEBUG_ELF_ROOT: ClassVar[Optional[str]] = None
+
     def __post_init__(self):
         if self.other_sections is None:
             self.other_sections = {}
 
-    def get_original_elf_path(self, elf_path="build/latest/.extapps") -> str:
+    def get_original_elf_path(self) -> str:
+        if self.DEBUG_ELF_ROOT is None:
+            raise ValueError("DEBUG_ELF_ROOT not set; call fap-set-debug-elf-root")
         return (
-            posixpath.join(elf_path, self.debug_link_elf)
-            if elf_path
+            posixpath.join(self.DEBUG_ELF_ROOT, self.debug_link_elf)
+            if self.DEBUG_ELF_ROOT
             else self.debug_link_elf
         )
 
@@ -84,7 +88,9 @@ class AppState:
         if debug_link_size := int(app_state["debug_link_info"]["debug_link_size"]):
             debug_link_data = (
                 gdb.selected_inferior()
-                .read_memory(int(app_state["debug_link_info"]["debug_link"]), debug_link_size)
+                .read_memory(
+                    int(app_state["debug_link_info"]["debug_link"]), debug_link_size
+                )
                 .tobytes()
             )
             state.debug_link_elf, state.debug_link_crc = AppState.parse_debug_link_data(
@@ -103,6 +109,29 @@ class AppState:
         return state
 
 
+class SetFapDebugElfRoot(gdb.Command):
+    """Set path to original ELF files for debug info"""
+
+    def __init__(self):
+        super().__init__(
+            "fap-set-debug-elf-root", gdb.COMMAND_FILES, gdb.COMPLETE_FILENAME
+        )
+        self.dont_repeat()
+
+    def invoke(self, arg, from_tty):
+        AppState.DEBUG_ELF_ROOT = arg
+        try:
+            global helper
+            print(f"Set '{arg}' as debug info lookup path for Flipper external apps")
+            helper.attach_fw()
+            gdb.events.stop.connect(helper.handle_stop)
+        except gdb.error as e:
+            print(f"Support for Flipper external apps debug is not available: {e}")
+
+
+SetFapDebugElfRoot()
+
+
 class FlipperAppDebugHelper:
     def __init__(self):
         self.app_ptr = None
@@ -149,9 +178,4 @@ class FlipperAppDebugHelper:
 
 
 helper = FlipperAppDebugHelper()
-try:
-    helper.attach_fw()
-    print("Support for Flipper external apps debug is enabled")
-    gdb.events.stop.connect(helper.handle_stop)
-except gdb.error as e:
-    print(f"Support for Flipper external apps debug is not available: {e}")
+print("Support for Flipper external apps debug is loaded")

+ 2 - 2
fbt_options.py

@@ -49,12 +49,12 @@ OPENOCD_OPTS = [
     "-c",
     "transport select hla_swd",
     "-f",
-    "debug/stm32wbx.cfg",
+    "${FBT_DEBUG_DIR}/stm32wbx.cfg",
     "-c",
     "stm32wbx.cpu configure -rtos auto",
 ]
 
-SVD_FILE = "debug/STM32WB55_CM4.svd"
+SVD_FILE = "${FBT_DEBUG_DIR}/STM32WB55_CM4.svd"
 
 # Look for blackmagic probe on serial ports and local network
 BLACKMAGIC = "auto"

+ 7 - 6
firmware.scons

@@ -20,8 +20,7 @@ env = ENV.Clone(
     BUILD_DIR=fw_build_meta["build_dir"],
     IS_BASE_FIRMWARE=fw_build_meta["type"] == "firmware",
     FW_FLAVOR=fw_build_meta["flavor"],
-    PLUGIN_ELF_DIR="${BUILD_DIR}",
-    LIB_DIST_DIR="${BUILD_DIR}/lib",
+    LIB_DIST_DIR=fw_build_meta["build_dir"].Dir("lib"),
     LINT_SOURCES=[
         "applications",
     ],
@@ -142,12 +141,14 @@ for app_dir, _ in env["APPDIRS"]:
 
 fwenv.PrepareApplicationsBuild()
 
-# Build external apps
+# Build external apps + configure SDK
 if env["IS_BASE_FIRMWARE"]:
-    extapps = fwenv["FW_EXTAPPS"] = SConscript(
-        "site_scons/extapps.scons", exports={"ENV": fwenv}
+    fwenv.SetDefault(FBT_FAP_DEBUG_ELF_ROOT="${BUILD_DIR}/.extapps")
+    fwenv["FW_EXTAPPS"] = SConscript(
+        "site_scons/extapps.scons",
+        exports={"ENV": fwenv},
     )
-    fw_artifacts.append(extapps["sdk_tree"])
+    fw_artifacts.append(fwenv["FW_EXTAPPS"].sdk_tree)
 
 
 # Add preprocessor definitions for current set of apps

+ 1 - 2
scripts/fbt/elfmanifest.py

@@ -5,6 +5,7 @@ import struct
 from dataclasses import dataclass, field
 
 from .appmanifest import FlipperApplication
+from flipper.assets.icon import file2image
 
 
 _MANIFEST_MAGIC = 0x52474448
@@ -53,8 +54,6 @@ def assemble_manifest_data(
 ):
     image_data = b""
     if app_manifest.fap_icon:
-        from flipper.assets.icon import file2image
-
         image = file2image(os.path.join(app_manifest._apppath, app_manifest.fap_icon))
         if (image.width, image.height) != (10, 10):
             raise ValueError(

+ 3 - 0
scripts/fbt/sdk/cache.py

@@ -89,6 +89,9 @@ class SdkCache:
         syms.update(map(lambda e: e.name, self.get_variables()))
         return syms
 
+    def get_disabled_names(self):
+        return set(map(lambda e: e.name, self.disabled_entries))
+
     def get_functions(self):
         return self._filter_enabled(self.sdk.functions)
 

+ 1 - 1
scripts/fbt_tools/fbt_assets.py

@@ -139,7 +139,7 @@ def generate(env):
         BUILDERS={
             "IconBuilder": Builder(
                 action=Action(
-                    '${PYTHON3} "${ASSETS_COMPILER}" icons ${ABSPATHGETTERFUNC(SOURCE)} ${TARGET.dir} --filename ${ICON_FILE_NAME}',
+                    '${PYTHON3} "${ASSETS_COMPILER}" icons "${ABSPATHGETTERFUNC(SOURCE)}" "${TARGET.dir}" --filename ${ICON_FILE_NAME}',
                     "${ICONSCOMSTR}",
                 ),
                 emitter=icons_emitter,

+ 10 - 6
scripts/fbt_tools/fbt_debugopts.py

@@ -1,7 +1,6 @@
 from re import search
 
 from SCons.Errors import UserError
-from fbt_options import OPENOCD_OPTS
 
 
 def _get_device_serials(search_str="STLink"):
@@ -20,6 +19,9 @@ def GetDevices(env):
 
 def generate(env, **kw):
     env.AddMethod(GetDevices)
+    env.SetDefault(
+        FBT_DEBUG_DIR="${ROOT_DIR}/debug",
+    )
 
     if (adapter_serial := env.subst("$OPENOCD_ADAPTER_SERIAL")) != "auto":
         env.Append(
@@ -36,7 +38,7 @@ def generate(env, **kw):
 
     env.SetDefault(
         OPENOCD_GDB_PIPE=[
-            "|openocd -c 'gdb_port pipe; log_output debug/openocd.log' ${[SINGLEQUOTEFUNC(OPENOCD_OPTS)]}"
+            "|openocd -c 'gdb_port pipe; log_output ${FBT_DEBUG_DIR}/openocd.log' ${[SINGLEQUOTEFUNC(OPENOCD_OPTS)]}"
         ],
         GDBOPTS_BASE=[
             "-ex",
@@ -58,17 +60,19 @@ def generate(env, **kw):
         ],
         GDBPYOPTS=[
             "-ex",
-            "source debug/FreeRTOS/FreeRTOS.py",
+            "source ${FBT_DEBUG_DIR}/FreeRTOS/FreeRTOS.py",
+            "-ex",
+            "source ${FBT_DEBUG_DIR}/flipperapps.py",
             "-ex",
-            "source debug/flipperapps.py",
+            "fap-set-debug-elf-root ${FBT_FAP_DEBUG_ELF_ROOT}",
             "-ex",
-            "source debug/PyCortexMDebug/PyCortexMDebug.py",
+            "source ${FBT_DEBUG_DIR}/PyCortexMDebug/PyCortexMDebug.py",
             "-ex",
             "svd_load ${SVD_FILE}",
             "-ex",
             "compare-sections",
         ],
-        JFLASHPROJECT="${ROOT_DIR.abspath}/debug/fw.jflash",
+        JFLASHPROJECT="${FBT_DEBUG_DIR}/fw.jflash",
     )
 
 

+ 2 - 2
scripts/fbt_tools/fbt_dist.py

@@ -22,7 +22,7 @@ def GetProjetDirName(env, project=None):
 
 def create_fw_build_targets(env, configuration_name):
     flavor = GetProjetDirName(env, configuration_name)
-    build_dir = env.Dir("build").Dir(flavor).abspath
+    build_dir = env.Dir("build").Dir(flavor)
     return env.SConscript(
         "firmware.scons",
         variant_dir=build_dir,
@@ -131,7 +131,7 @@ def generate(env):
             "UsbInstall": Builder(
                 action=[
                     Action(
-                        '${PYTHON3} "${SELFUPDATE_SCRIPT}" dist/${DIST_DIR}/f${TARGET_HW}-update-${DIST_SUFFIX}/update.fuf'
+                        '${PYTHON3} "${SELFUPDATE_SCRIPT}" ${UPDATE_BUNDLE_DIR}/update.fuf'
                     ),
                     Touch("${TARGET}"),
                 ]

+ 54 - 37
scripts/fbt_tools/fbt_extapps.py

@@ -1,6 +1,9 @@
+from dataclasses import dataclass, field
+from typing import Optional
 from SCons.Builder import Builder
 from SCons.Action import Action
 from SCons.Errors import UserError
+from SCons.Node import NodeList
 import SCons.Warnings
 
 from fbt.elfmanifest import assemble_manifest_data
@@ -16,6 +19,15 @@ import shutil
 from ansi.color import fg
 
 
+@dataclass
+class FlipperExternalAppInfo:
+    app: FlipperApplication
+    compact: NodeList = field(default_factory=NodeList)
+    debug: NodeList = field(default_factory=NodeList)
+    validator: NodeList = field(default_factory=NodeList)
+    installer: NodeList = field(default_factory=NodeList)
+
+
 def BuildAppElf(env, app):
     ext_apps_work_dir = env.subst("$EXT_APPS_WORK_DIR")
     app_work_dir = os.path.join(ext_apps_work_dir, app.appid)
@@ -26,15 +38,7 @@ def BuildAppElf(env, app):
 
     app_alias = f"fap_{app.appid}"
 
-    # Deprecation stub
-    legacy_app_taget_name = f"{app_env['FIRMWARE_BUILD_CFG']}_{app.appid}"
-
-    def legacy_app_build_stub(**kw):
-        raise UserError(
-            f"Target name '{legacy_app_taget_name}' is deprecated, use '{app_alias}' instead"
-        )
-
-    app_env.PhonyTarget(legacy_app_taget_name, Action(legacy_app_build_stub, None))
+    app_artifacts = FlipperExternalAppInfo(app)
 
     externally_built_files = []
     if app.fap_extbuild:
@@ -115,20 +119,22 @@ def BuildAppElf(env, app):
         CPPPATH=env.Dir(app_work_dir),
     )
 
-    app_elf_raw = app_env.Program(
+    app_artifacts.debug = app_env.Program(
         os.path.join(ext_apps_work_dir, f"{app.appid}_d"),
         app_sources,
         APP_ENTRY=app.entry_point,
     )
 
-    app_env.Clean(app_elf_raw, [*externally_built_files, app_env.Dir(app_work_dir)])
+    app_env.Clean(
+        app_artifacts.debug, [*externally_built_files, app_env.Dir(app_work_dir)]
+    )
 
-    app_elf_dump = app_env.ObjDump(app_elf_raw)
+    app_elf_dump = app_env.ObjDump(app_artifacts.debug)
     app_env.Alias(f"{app_alias}_list", app_elf_dump)
 
-    app_elf_augmented = app_env.EmbedAppMetadata(
+    app_artifacts.compact = app_env.EmbedAppMetadata(
         os.path.join(ext_apps_work_dir, app.appid),
-        app_elf_raw,
+        app_artifacts.debug,
         APP=app,
     )
 
@@ -139,19 +145,21 @@ def BuildAppElf(env, app):
     }
 
     app_env.Depends(
-        app_elf_augmented,
+        app_artifacts.compact,
         [app_env["SDK_DEFINITION"], app_env.Value(manifest_vals)],
     )
     if app.fap_icon:
         app_env.Depends(
-            app_elf_augmented,
+            app_artifacts.compact,
             app_env.File(f"{app._apppath}/{app.fap_icon}"),
         )
 
-    app_elf_import_validator = app_env.ValidateAppImports(app_elf_augmented)
-    app_env.AlwaysBuild(app_elf_import_validator)
-    app_env.Alias(app_alias, app_elf_import_validator)
-    return (app_elf_augmented, app_elf_raw, app_elf_import_validator)
+    app_artifacts.validator = app_env.ValidateAppImports(app_artifacts.compact)
+    app_env.AlwaysBuild(app_artifacts.validator)
+    app_env.Alias(app_alias, app_artifacts.validator)
+
+    env["EXT_APPS"][app.appid] = app_artifacts
+    return app_artifacts
 
 
 def prepare_app_metadata(target, source, env):
@@ -182,11 +190,17 @@ def validate_app_imports(target, source, env):
             app_syms.add(line.split()[0])
     unresolved_syms = app_syms - sdk_cache.get_valid_names()
     if unresolved_syms:
-        SCons.Warnings.warn(
-            SCons.Warnings.LinkWarning,
-            fg.brightyellow(f"{source[0].path}: app won't run. Unresolved symbols: ")
-            + fg.brightmagenta(f"{unresolved_syms}"),
-        )
+        warning_msg = fg.brightyellow(
+            f"{source[0].path}: app won't run. Unresolved symbols: "
+        ) + fg.brightmagenta(f"{unresolved_syms}")
+        disabled_api_syms = unresolved_syms.intersection(sdk_cache.get_disabled_names())
+        if disabled_api_syms:
+            warning_msg += (
+                fg.brightyellow(" (in API, but disabled: ")
+                + fg.brightmagenta(f"{disabled_api_syms}")
+                + fg.brightyellow(")")
+            )
+        SCons.Warnings.warn(SCons.Warnings.LinkWarning, warning_msg),
 
 
 def GetExtAppFromPath(env, app_dir):
@@ -208,26 +222,26 @@ def GetExtAppFromPath(env, app_dir):
     if not app:
         raise UserError(f"Failed to resolve application for given APPSRC={app_dir}")
 
-    app_elf = env["_extapps"]["compact"].get(app.appid, None)
-    if not app_elf:
+    app_artifacts = env["EXT_APPS"].get(app.appid, None)
+    if not app_artifacts:
         raise UserError(
             f"Application {app.appid} is not configured for building as external"
         )
 
-    app_validator = env["_extapps"]["validators"].get(app.appid, None)
-
-    return (app, app_elf[0], app_validator[0])
+    return app_artifacts
 
 
 def fap_dist_emitter(target, source, env):
     target_dir = target[0]
 
     target = []
-    for dist_entry in env["_extapps"]["dist"].values():
-        target.append(target_dir.Dir(dist_entry[0]).File(dist_entry[1][0].name))
-
-    for compact_entry in env["_extapps"]["compact"].values():
-        source.extend(compact_entry)
+    for _, app_artifacts in env["EXT_APPS"].items():
+        source.extend(app_artifacts.compact)
+        target.append(
+            target_dir.Dir(app_artifacts.app.fap_category).File(
+                app_artifacts.compact[0].name
+            )
+        )
 
     return (target, source)
 
@@ -244,10 +258,9 @@ def fap_dist_action(target, source, env):
 
 def generate(env, **kw):
     env.SetDefault(
-        EXT_APPS_WORK_DIR=kw.get("EXT_APPS_WORK_DIR"),
+        EXT_APPS_WORK_DIR="${FBT_FAP_DEBUG_ELF_ROOT}",
         APP_RUN_SCRIPT="${FBT_SCRIPT_DIR}/runfap.py",
     )
-
     if not env["VERBOSE"]:
         env.SetDefault(
             FAPDISTCOMSTR="\tFAPDIST\t${TARGET}",
@@ -256,6 +269,10 @@ def generate(env, **kw):
             APPCHECK_COMSTR="\tAPPCHK\t${SOURCE}",
         )
 
+    env.SetDefault(
+        EXT_APPS={},  # appid -> FlipperExternalAppInfo
+    )
+
     env.AddMethod(BuildAppElf)
     env.AddMethod(GetExtAppFromPath)
     env.Append(

+ 6 - 4
scripts/requirements.txt

@@ -1,7 +1,9 @@
-pyserial==3.5
+ansi==0.3.6
+black==22.6.0
+colorlog==6.7.0
 heatshrink2==0.11.0
 Pillow==9.1.1
-grpcio==1.47.0
-grpcio-tools==1.47.0
-protobuf==3.20.2
+protobuf==3.20.1
+pyserial==3.5
 python3-protobuf==2.5.0
+SCons==4.4.0

+ 1 - 1
site_scons/commandline.scons

@@ -147,7 +147,7 @@ vars.AddVariables(
     PathVariable(
         "SVD_FILE",
         help="Path to SVD file",
-        validator=PathVariable.PathIsFile,
+        validator=PathVariable.PathAccept,
         default="",
     ),
     PathVariable(

+ 1 - 1
site_scons/environ.scons

@@ -61,8 +61,8 @@ coreenv = VAR_ENV.Clone(
     ABSPATHGETTERFUNC=extract_abs_dir_path,
     # Setting up temp file parameters - to overcome command line length limits
     TEMPFILEARGESCFUNC=tempfile_arg_esc_func,
-    FBT_SCRIPT_DIR=Dir("#/scripts"),
     ROOT_DIR=Dir("#"),
+    FBT_SCRIPT_DIR="${ROOT_DIR}/scripts",
 )
 
 # If DIST_SUFFIX is set in environment, is has precedence (set by CI)

+ 30 - 43
site_scons/extapps.scons

@@ -1,4 +1,6 @@
+from dataclasses import dataclass, field
 from SCons.Errors import UserError
+from SCons.Node import NodeList
 
 
 Import("ENV")
@@ -7,14 +9,7 @@ from fbt.appmanifest import FlipperAppType
 
 appenv = ENV["APPENV"] = ENV.Clone(
     tools=[
-        (
-            "fbt_extapps",
-            {
-                "EXT_APPS_WORK_DIR": ENV.subst(
-                    "${BUILD_DIR}/.extapps",
-                )
-            },
-        ),
+        "fbt_extapps",
         "fbt_assets",
         "fbt_sdk",
     ]
@@ -60,22 +55,11 @@ appenv.AppendUnique(
 )
 
 
-extapps = appenv["_extapps"] = {
-    "compact": {},
-    "debug": {},
-    "validators": {},
-    "dist": {},
-    "resources_dist": None,
-    "sdk_tree": None,
-}
-
-
-def build_app_as_external(env, appdef):
-    compact_elf, debug_elf, validator = env.BuildAppElf(appdef)
-    extapps["compact"][appdef.appid] = compact_elf
-    extapps["debug"][appdef.appid] = debug_elf
-    extapps["validators"][appdef.appid] = validator
-    extapps["dist"][appdef.appid] = (appdef.fap_category, compact_elf)
+@dataclass
+class FlipperExtAppBuildArtifacts:
+    applications: dict = field(default_factory=dict)
+    resources_dist: NodeList = field(default_factory=NodeList)
+    sdk_tree: NodeList = field(default_factory=NodeList)
 
 
 apps_to_build_as_faps = [
@@ -85,38 +69,39 @@ apps_to_build_as_faps = [
 if appenv["DEBUG_TOOLS"]:
     apps_to_build_as_faps.append(FlipperAppType.DEBUG)
 
-for apptype in apps_to_build_as_faps:
-    for app in appenv["APPBUILD"].get_apps_of_type(apptype, True):
-        build_app_as_external(appenv, app)
+known_extapps = [
+    app
+    for apptype in apps_to_build_as_faps
+    for app in appenv["APPBUILD"].get_apps_of_type(apptype, True)
+]
 
 # Ugly access to global option
 if extra_app_list := GetOption("extra_ext_apps"):
-    for extra_app in extra_app_list.split(","):
-        build_app_as_external(appenv, appenv["APPMGR"].get(extra_app))
-
-
-if appenv["FORCE"]:
-    appenv.AlwaysBuild(extapps["compact"].values())
-
+    known_extapps.extend(map(appenv["APPMGR"].get, extra_app_list.split(",")))
 
-# Deprecation stub
-def legacy_app_build_stub(**kw):
-    raise UserError(f"Target name 'firmware_extapps' is deprecated, use 'faps' instead")
+for app in known_extapps:
+    appenv.BuildAppElf(app)
 
 
-appenv.PhonyTarget("firmware_extapps", appenv.Action(legacy_app_build_stub, None))
+if appenv["FORCE"]:
+    appenv.AlwaysBuild(
+        list(app_artifact.compact for app_artifact in appenv["EXT_APPS"].values())
+    )
 
 
-Alias("faps", extapps["compact"].values())
-Alias("faps", extapps["validators"].values())
+Alias(
+    "faps", list(app_artifact.validator for app_artifact in appenv["EXT_APPS"].values())
+)
 
-extapps["resources_dist"] = appenv.FapDist(appenv.Dir("#/assets/resources/apps"), [])
+extapps = FlipperExtAppBuildArtifacts()
+extapps.applications = appenv["EXT_APPS"]
+extapps.resources_dist = appenv.FapDist(appenv.Dir("#/assets/resources/apps"), [])
 
 if appsrc := appenv.subst("$APPSRC"):
     app_manifest, fap_file, app_validator = appenv.GetExtAppFromPath(appsrc)
     appenv.PhonyTarget(
         "launch_app",
-        '${PYTHON3} "${APP_RUN_SCRIPT}" ${SOURCE} --fap_dst_dir "/ext/apps/${FAP_CATEGORY}"',
+        '${PYTHON3} "${APP_RUN_SCRIPT}" "${SOURCE}" --fap_dst_dir "/ext/apps/${FAP_CATEGORY}"',
         source=fap_file,
         FAP_CATEGORY=app_manifest.fap_category,
     )
@@ -131,12 +116,14 @@ sdk_source = appenv.SDKPrebuilder(
     (appenv["SDK_HEADERS"], appenv["FW_ASSETS_HEADERS"]),
 )
 # Extra deps on headers included in deeper levels
+# Available on second and subsequent builds
 Depends(sdk_source, appenv.ProcessSdkDepends(f"{sdk_origin_path}.d"))
 
 appenv["SDK_DIR"] = appenv.Dir("${BUILD_DIR}/sdk")
-sdk_tree = extapps["sdk_tree"] = appenv.SDKTree(appenv["SDK_DIR"], sdk_origin_path)
+sdk_tree = appenv.SDKTree(appenv["SDK_DIR"], sdk_origin_path)
 # AlwaysBuild(sdk_tree)
 Alias("sdk_tree", sdk_tree)
+extapps.sdk_tree = sdk_tree
 
 sdk_apicheck = appenv.SDKSymUpdater(appenv["SDK_DEFINITION"], sdk_origin_path)
 Precious(sdk_apicheck)