SHIPPED + hardware-verified 2026-06-25. SD-card-loadable apps now work end-to-end: apps load from loose-file directories and the launcher shows a tile per app (DiceApp, CounterApp, HelloWorldApp, PaintApp). NOTE: the real on-device path is
D:\apps(one dir per app, loose files), NOT the/sd/apps/path sketched throughout this design doc. SD read/write itself ships over WebRTC (sys.files, chunked, hardware-verified 2026-06-24) and runs on SDSPI (SPI3_HOST). The rest of this file is the original design sketch — kept for the runtime-constraint research it captured; treat path/manifest specifics as the design intent, not the as-built layout.
The 32 GB microSD slot on the Waveshare watch makes "user-installable apps" a viable feature. nanoFramework DOES support runtime assembly loading via Assembly.Load(byte[]) - I was wrong about this in an earlier draft of this file. The native impl is at targets/.../src/CLR/CorLib/corlib_native_System_Reflection_Assembly.cpp:277 and it does the full pipeline: parse the .pe header, link into the type system (g_CLR_RT_TypeSystem.Link), resolve references (ResolveAll), prepare for execution, and spawn static constructors.
The one caveat: if the loaded assembly has CLR_RECORD_ASSEMBLY::c_Flags_NeedReboot set in its header (line 983 of nanoCLR_Types.h), the load returns CLR_E_BUSY - those assemblies require a reboot to register. The flag is set on assemblies that contain native (non-managed) entry points - i.e. anything that calls into a hand-rolled C++ method via the native-stubs path. Pure managed code from a .nfproj should NOT have this flag set, so app assemblies that consume system services through the public managed surface (which is what we want anyway) load cleanly.
This means the architecture Gemini sketched in TJ's 2026-05-04 conversation is the correct shape: microkernel-style core firmware in flash, app payloads on the TF card, dynamic load via Assembly.Load(byte[]). Phase 8 territory; this file is the design sketch so we know what we're aiming at.
An app is a registration entry the launcher consumes:
- Tile metadata: label, icon, optional badge count source, optional accent color
- Screen factory: a function that returns an
IScreenwhen the tile is tapped - Required services: list of system service interfaces the app needs to function
Built-in apps are hard-coded today in Program.cs. SD-card apps would arrive at boot via:
- Manifest scan - launcher reads
/sd/apps/*/manifest.jsonand registers metadata for each found app - Code load - the actual app behavior comes from one of three options listed below
Inspired by TJ's 2026-05-04 design conversation with Gemini:
Core firmware (internal flash; ~~~290 KB headroom under the deploy ceiling~~ — deploy ceiling RESOLVED 2026-06-25, full 2.94 MB partition usable):
- HAL / drivers (CO5300, FT3168, AXP2101, PCF85063, etc.) - nothing leaves
- System services (Power, WiFi, BLE, RTC, Audio, Storage, Logger, App Loader)
- UI Framework (drawing primitives, navigation, system widgets)
- The launcher
- Recovery apps (Settings, About, System Stats) - these MUST stay in flash so the watch is functional even if the SD card is removed, corrupted, or unmounted
External apps (microSD card):
- One folder per app:
/sd/apps/com.tj.calendar/manifest.json- {name, version, icon (optional), accent (color), required services}app.pe- compiled managed assembly implementingISpawnAppassets/- app-private read-only resources (icons, sounds, localized strings)data/- app-private read-write storage; persists across uninstalls if akeep-datamarker file is present
- Apps are dynamically loaded via
Assembly.Load(byte[])when the user taps the tile - Apps are unloaded when the user navigates away (assembly + heap freed)
Shared contracts in a small SpawnWear.AppContracts.dll that both the firmware AND every app reference:
ISpawnApp- lifecycle interface (OnCreate(IServiceHost services),OnResume,OnPause,OnDestroy,Tick(deltaMs),OnTap(x, y))IServiceHost- the surface apps use to ask forIPowerService,IRtcService,IDisplayBuffer, etc.- The framework owns the renderer; apps call
IDisplayBuffer.FillRectangle / DrawString / Flushrather than bringing their own font.
App load + launch flow:
- Launcher boot - scan
/sd/apps/*/manifest.json, register tile metadata in the launcher's tile list - User taps tile - launcher shows a "Loading..." transition (Gemini's suggestion - hides the SPI read latency)
- Read
/sd/apps/<name>/app.peinto a byte[] - Wrap in try/catch.
var asm = Assembly.Load(payload); - Find the type implementing
ISpawnApp, instantiate it, callOnCreate(serviceHost), thenOnResume - Push the app's
IScreen-equivalent onto the navigation stack - On exit,
OnPause→OnDestroy, drop the reference, let the GC reclaim the assembly + its heap
(Done 2026-05-04 against D:/users/tj/Projects/SpawnWear/_vendor-nf-interpreter/. Updated from "we need to verify" to "this is what the code does.")
CLR_RT_Assembly::DestroyInstance()exists (Core/TypeSystem.cpp:1885). It (a) clears the type-system slot viag_CLR_RT_TypeSystem.m_assemblies[m_idx - 1] = NULL, (b) frees the header memory IF theFreeOnDestroyflag is set (m_flags & 0x100), and (c) appends the assembly object to the event cache for recycling.- It is called from two places: the Load() error path (
CorLib/corlib_native_System_Reflection_Assembly.cpp:395, only on failure) andCLR_RT_TypeSystem::TypeSystem_Cleanup()(Core/TypeSystem.cpp:3438, only at full CLR shutdown). - No public managed-side API wraps DestroyInstance for a successfully-loaded assembly. Gemini was right that there's no
Assembly.Unload(). FreeOnDestroyis NOT set on byte[]-loaded assemblies. The Load() native impl rooted the byte[] inassm->m_pFile = array;(corlib_native_System_Reflection_Assembly.cpp:319) and never sets the flag. So even if DestroyInstance were called, the header memory stays alive (it's GC-managed via the byte[], not malloc-allocated).CLRStartup.cpp:306is the only place that DOES setFreeOnDestroy, and only for non-XIP startup loads.- Cross-reference dangling-pointer concern: real but bounded. Other assemblies'
m_pCrossReference_AssemblyRef[i].m_targetwould point to a recycled CLR_RT_Assembly.NANOCLR_FOREACH_ASSEMBLYiteration skips NULL slots inm_assemblies[], so type-system traversal stays safe; but specific resolved cross-references would dangle. Don't unload an assembly that other live assemblies reference.
Implication for the V1 plan: a "Soft Reboot (ClrOnly)" cycle is the only full-reclaim path. Apps can be loaded freely until heap pressure builds up, at which point a reboot is needed. UI design should treat reboot as cheap (~3 s) and explicit ("Restart the watch to reclaim memory") rather than something to hide.
- Actual behavior (
corlib_native_System_Reflection_Assembly.cpp:312-315):if (header->flags & CLR_RECORD_ASSEMBLY::c_Flags_NeedReboot) NANOCLR_SET_AND_LEAVE(CLR_E_BUSY);. That's an HRESULT failure, propagated as an exception to managed code. NOT a forced reboot. NOT a "LinkFailure". - The managed caller decides what to do: catch the exception, prompt the user, save a "pending app" pointer, call
Power.RebootDevice(RebootOption.ClrOnly). None of that is built in - we'd have to write it. - What sets the flag in the .pe header: the build pipeline sets
c_Flags_NeedRebootwhen the assembly contains native interop method definitions whose checksum doesn't match the running CLR's compiled-in native methods table. Pure managed app code referencing only managed surfaces should NOT have this flag. - Verification step: build a "hello world"
.pefor the watch (managed-only, referencesSpawnWear.AppContracts.dllonly), dump the first 32 bytes of the file, check theflagsfield at offset 16 (CLR_RECORD_ASSEMBLYlayout: 8-byte marker, 4-byte headerCRC, 4-byte assemblyCRC, then 4-byte flags). Bit 0 should be clear.
- Gemini's claim "loader returns the existing handle" is WRONG for the Assembly.Load(byte[]) path. Looking at
corlib_native_System_Reflection_Assembly.cpp:317-321:CreateInstance(header, assm)followed immediately byg_CLR_RT_TypeSystem.Link(assm). There is NOFindAssembly()call before linking. Link()itself (Core/TypeSystem.cpp:3454) iteratesNANOCLR_FOREACH_ASSEMBLY_NULLto find the first NULL slot and stores the new pointer there. Both the existing AND the newly-loaded assembly end up inm_assemblies[], in different slots.FindAssembly(name, version, exact)(Core/TypeSystem.cpp:3486) iterates and returns the FIRST match - so subsequent type-resolution lookups would consistently pick whichever assembly happens to be in the lower-indexed slot. Two distinct CLR_RT_Assembly instances exist, but only one is reachable via name lookup.- AppDomain-level deduplication DOES exist (
Core/TypeSystem.cpp:2284-2286:if (FindAppDomainAssembly(assm) != NULL) return S_OK;), but that's at a higher layer that the Load(byte[]) path doesn't traverse for its initial Link. CLR_E_ASSM_WRONG_CHECKSUM(nf_errors_exceptions.h:83) is for native-interop checksum mismatches at deploy-time, not for name collisions. Different code path.
Implication: don't rely on the loader to dedupe. The launcher's app loader MUST call Assembly.GetAssemblies() (or equivalent metadata API), check for an existing match by name+version, and either skip the load or fail the install BEFORE handing bytes to the CLR.
- An app built against
SpawnWear.AppContracts v1.0may load against firmware running v1.1 IF the version comparison inFindAssemblyaccepts non-exact matches. ThefExactparameter (line 3500) is what controls this. - For interfaces specifically, the contract surface is the method signatures recorded in the .pe metadata. Adding a method to
ISpawnAppwould change the interface's metadata and potentially break apps built against the older contract. - Design rule:
SpawnWear.AppContracts.dllmust be APPEND-ONLY at the type level. Existing methods onISpawnAppnever change. New capabilities arrive as new interfaces (IAppHasNotifications,IAppHasBackgroundService) that the firmware checks viaiscasts at runtime.
- Asset caching (Gemini's idea, good): launcher caches app icons + tile metadata in a single binary file in internal flash so the home screen doesn't re-scan the SD tree on every navigation back to launcher. Cache invalidates on app install / uninstall (manifest mtime changes).
- Loading indicator: tapping a tile flips to a "Loading..." screen immediately; the actual SPI read + Assembly.Load happens behind that. Even a 200 ms load feels less janky if there's a transition than if the UI freezes.
- Same-thread execution: apps run on the framework's UI thread (via
Tick+OnTapcallbacks). They don't get their own thread. Background work goes through services. This matches Android's UI-thread model and avoids the most common class of plugin bugs (concurrent access to the framebuffer). - Crash isolation: every call into an app goes through
try / catch (Exception). An exception from an app surfaces as a one-shot toast + a return-to-launcher; the firmware never dies for an app's bug.
/sd/
apps/
com.tj.calendar/
manifest.json <- {name, version, icon, requires: [Storage, RTC]}
icon.png <- 96x96 PNG, optional (falls back to a generic tile)
payload.pe <- Option C only; the actual managed assembly
data/ <- app-private storage, persists across uninstalls if marker file present
com.aubs.draw/
manifest.json
icon.png
data/
sketches/
system/
log/
2026-05-04.log
2026-05-03.log
settings.json <- system service config, mirrored to internal flash
In order:
- Verify the runtime constraints above with a tiny throwaway harness - loading + unloading + reloading a "hello world" app, watching for leaks and
c_Flags_NeedRebootissues - Define
SpawnWear.AppContracts.dll-ISpawnAppinterface +IServiceHost+ the small set of capability interfaces (rendering, RTC, storage). Check it into the SpawnWear repo as a separate.nfprojso apps can take it as a NuGet reference. - Implement
AppLoadersystem service in the core firmware - scans/sd/apps/*/manifest.jsonat boot, exposes registered apps to the launcher, dynamically loads + unloads on demand. - Convert one built-in app to live on SD as a smoke test (Stats or Activity is a good candidate - simple, no audio / WebRTC complexity).
- First user-facing release: drop a sample
com.tj.helloworld.peon the SD card, document the build pipeline, demo it on the watch.
- Discoverability: how does someone get an app onto the SD card in the first place? Companion PWA upload over BLE? USB MSC mode? Initial answer: copy the
.peto/sd/apps/<name>/app.pewhile the SD card is mounted on a host PC. Long term: PWA-driven OTA install viaWriteValueAsyncchunks over BLE GATT. - Code-signing: do we want it before user-installable apps ship? The watch is going to live on TJ's wrist; the threat model for accepting random
.pefiles isn't the same as a phone app store, but it isn't zero either. Probably ship V1 without signing, add it before "user-contributed apps via the install path" lands in Phase 9. - Sandboxing: a user-installed app gets the same access as a built-in one today. Phase 9+ might want capability-limited apps (e.g. "no WiFi access"). The
IServiceHostdesign should make this expressible: a capability-restricted host hands out a subset of services. - App-side dependencies: if every app could bring its own NuGet refs, the SD card becomes a dependency-hell minefield. V1 says: apps may only reference
SpawnWear.AppContracts.dll+ the specific managed assemblies the firmware already loads. No transitive dependencies. Apps build clean against a known firmware version's contract surface or they fail to load.
An earlier version of this file said Assembly.Load(byte[]) doesn't work in nanoFramework. That was based on a bad recollection rather than reading the source. It does work - see corlib_native_System_Reflection_Assembly.cpp::Load___STATIC__SystemReflectionAssembly__SZARRAY_U1 in the LostBeard nf-interpreter fork. Rule 4b violation - corrected here so the next reader doesn't propagate the wrong assumption.
The full dynamic-load + invoke path verified end-to-end on the watch:
- Built a minimal
HelloWorldApp.pe(416 bytes, managed-only, references mscorlib only).tools/check-pe-header.csconfirmedflags = 0andnativeMethodsChecksum = 0. - Added a
POST /loadpeHTTP endpoint to SpawnWear'sHttpServer.csthat reads the request body as a byte[], runsSystem.Reflection.Assembly.Load(byte[]), finds the typeHelloWorldApp.HelloWorldPayload, invokes static methodGreet()via reflection, returns the result string in the HTTP response. - POSTed the .pe file via curl:
curl -X POST --data-binary @HelloWorldApp.pe http://192.168.1.171:8080/loadpe - Watch responded:
OK: Hello from SD-card-loadable app, watch is at 05/04/2026 12:20:29
This confirms the architectural assumptions:
Assembly.Load(byte[])works at runtime on the LostBeard nf-interpreter fork as deployed- Pure-managed assemblies (flags = 0) load cleanly without
CLR_E_BUSY - Loaded assemblies are reachable via
assembly.GetType("...") - Static methods on loaded types are invocable via
MethodInfo.Invoke(null, null) - Return values cross the assembly boundary correctly
Phase 8 is unblocked. The full app loader can be built on top of this foundation with confidence the foundation is real.
What remains untested (as of the 2026-05-05 validation):
- Assembly unload + heap reclaim across many load cycles (Gemini's "metadata never reclaimed" claim - needs a 100x load test)
- Cross-assembly interface calls (loaded app implementing
ISpawnAppfrom the firmware-deployed AppContracts) - Same-name+version collision behavior on a real load attempt
Loading from SD card (FileSystem package not yet on the firmware)DONE 2026-06-25 — apps load fromD:\appsloose-file dirs; SD read/write hardware-verified 2026-06-24. Launcher shows a tile per app (DiceApp / CounterApp / HelloWorldApp / PaintApp).