Skip to content

Capabilities

Edgar Mesquita edited this page Aug 18, 2026 · 12 revisions

Device capabilities

🌐 This page in: English · Português

What the machine can do (camera, location, photos, biometrics, the network) as services taken through a constructor, realized per host. A component asks for a capability and never learns which target answered.

public sealed class ScanShell(ICamera? camera, ILocation? location) : StatefulComponent
{
    private async void LocateAsync()
    {
        if (location is null) return;             // this host has none
        var here = await location.GetCurrentAsync();
    }
}

Nullable, always. A host that cannot do something registers nothing, so the service resolves to null and the app shows the thing it already knows how to show. That is the framework's answer everywhere: report the absence rather than pretend with a stub that fails later, at a worse moment.

Capability What it answers Since
IPhotoLibrary GetPermissionAsync(), PickImageAsync(), one picture the user chose 0.2.0-preview.1
ICamera Capture() for a still, StartPreviewAsync() for an ICameraSession (a live texture) 0.2.0-preview.1
ILocation GetCurrentAsync(), and Subscribe(…) for the stream of changes 0.2.0-preview.1
IBiometrics IsAvailable, AuthenticateAsync(reason): Face ID / Touch ID / the platform's own 0.2.0-preview.1
IMotionSensor Subscribe(…) for device motion readings 0.2.0-preview.1
INetworkStatus Current, and Subscribe(…): reachability and what carries it 0.2.0-preview.1
ITextClipboard Read() / Write(text), for a page's own Copy button 0.2.0-preview.1
IThemeController The light/dark switch. See ServerIntegration 0.2.0-preview.1
IAppStorage / ISecretStore Durable preferences and secrets. See Storage 0.2.0-preview.10
IClock Every(interval, onTick): time itself, as something a component can subscribe to 0.2.0-preview.31

Since 0.2.0-preview.31

Answers that do not hold still

ILocation, IMotionSensor and INetworkStatus are the ones whose answer CHANGES, so they hand back an IDisposable from Subscribe rather than a value. Dispose it when the component goes away, or the subscription outlives what it was updating.

IClock is the one whose answer is nothing BUT change, and it is what a screen that advances itself was missing: a carousel, a live counter, a status that polls. Everything else here reacts to something a person did.

public sealed class Carousel(IClock clock) : StatefulComponent
{
    private IDisposable? _tick;
    private int _slide;

    protected override void OnMount() =>
        _tick = clock.Every(TimeSpan.FromSeconds(4), () => SetState(() => _slide = (_slide + 1) % 3));

    protected override void OnUnmount() => _tick?.Dispose();
}

The pair is the contract: OnMount subscribes, OnUnmount disposes, and without the second half every navigation leaves a timer running against a component nobody can see. On a SERVER the clock never ticks, which is the right answer rather than a missing one: a server renders one frame, so the first paint is the state the component was built with and the ticking starts at hydration. A phone that spent ten minutes in a pocket comes back to ONE tick, never to six hundred.

It is a PERIODIC clock and not a frame clock, deliberately. Per-frame animation needs two things that do not exist yet, positional state retention through the reconciler and a geometry channel in StyleChannels, and a 60Hz callback without them would be the slowest way to animate.

INetworkStatus reports reachability as the platform sees it, not a promise a specific host will answer. An app showing a stale "online" is worse than one that says nothing.

Permissions

PermissionState has four answers, and the third is the one apps forget:

  • NotDetermined: never asked. Asking shows the system prompt.
  • Granted.
  • Denied: refused, and asking again does nothing. Only the system's settings can change it, so a button that re-asks is a button that does nothing; send them to settings instead.
  • The platform may also report a restriction the user cannot lift at all.

On native, the strings the OS shows at the prompt come from the assembly:

[assembly: PhotonCapability("camera", "Scan a barcode to add an item.")]

The build reads those into the platform's manifest (Info.plist, the Android manifest), so the reason a user reads is stated once, in the app, next to the code that needs it.

Crosses by NAME, never by ordinal. The manifest matches the capability's string. Inserting a value into the middle of an enum once turned Location into Motion in a shipped manifest: the build was green and the app asked for the wrong permission.

The web

The browser realizes what it honestly can (photos through a file input, camera and location and network through their web APIs) and registers the rest as unavailable rather than absent, so a page that takes one still receives an object and shows its fallback instead of failing to construct.

A page takes what it needs through its constructor on every target:

public sealed class ProfilePage(IPhotoLibrary photos) : StatefulComponent
{
    public override VisualNode Build(ComponentContext context) =>
        photos.IsAvailable ? Button(label: "Choose a picture", onPressed: Pick) : Text("No library here");
}

Natively ActivatorUtilities resolves it. In the browser the transpiled constructor resolves it itself, by the interface's NAME: a C# type does not exist at run time there, but IPhotoLibrary as a string does, and both sides agree on it.

Whichever constructor form declares it. A primary constructor took a different path through the parser until 0.2.0-preview.33, and a section written PairLoop(IClock clock) got if (clock !== undefined) this.clock = clock instead: nobody composing it in the middle of a tree passes a clock, so the field stayed undefined and the component was inert, in silence, on one target only.

Nobody passes it, either. The dependency leaves the emitted constructor, so it also leaves the CALL SITE and the generated factory: Quark(mood, size) is the whole signature a caller sees, and new Quark(clock, mood, size) in C# drops the clock on the way out rather than sliding every argument after it one place along. The rule is asked of the model, not guessed from a name: a constructor parameter whose type is an INTERFACE is a dependency, everything else is data the caller passes. A component takes what it draws (a label, a variant, a callback) and none of those is ever an interface. (IReadOnlyList<T> and IEnumerable<T> are data, explicitly.)

A component in the middle of a tree

Since 0.2.0-preview.18

public sealed class CopyButton : StatelessComponent
{
    public override VisualNode Build(ComponentContext context) =>
        context.GetService<ITextClipboard>() is { } clipboard
            ? IconButton(Icons.Copy, onPressed: () => clipboard.Write(_code))
            : new Box();   // no clipboard here, so draw nothing rather than a dead button
}

Constructor injection stays the better answer where it fits: explicit, testable, readable in the signature. But it only reaches the PAGE. Everything below had to be handed the same thing by hand: a card with a Copy button needs an ITextClipboard, so the article above it carried one it never used, and the section above that carried it too. A component that gained a need forced an edit in every ancestor between it and the page.

context.GetService<T>() answers null when the target does not have the capability, which is the answer every capability's caller has to handle anyway.

Where it resolves FROM is the host's business: SSR uses the REQUEST's container (so a scoped registration works and a page's own registrations win), the browser uses what the boot registered, a Photon app uses the shell's. The component asks the same question everywhere.

On the WEB this arrived working only in 0.2.0-preview.21. The type argument was dropped in transpilation: the strategy that turns the call into a key recognised the older Core RenderContext and an IServiceProvider, and ComponentContext is neither, so the call fell through to the ordinary invocation path, which drops type arguments. Every page asked for a capability by no name at all and got null back, on the one target the feature exists to serve.

Where there is no context

Since 0.2.0-preview.33

public sealed class PairLoop : StatefulComponent
{
    private IDisposable? _tick;

    protected override void OnMount() =>
        _tick = GetService<IClock>()?.Every(Beat, () => SetState(() => _step++));

    protected override void OnUnmount() => _tick?.Dispose();
}

OnMount is where a subscription belongs. It runs once, on the instance the reconciler keeps, and it pairs with OnUnmount, which is what stops a navigated away page ticking forever. It receives no ComponentContext, so before this the only way to reach a capability at depth was inside Build behind a run once flag: the subscription lived in the one method the framework calls repeatedly, and the pairing with OnUnmount read as an accident.

The accessor on the component answers exactly what context.GetService<T>() answers, from anywhere in the component: a lifecycle hook, an event handler, Build itself.

What a missing capability does

Since 0.2.0-preview.33

The C# already says it, so the framework reads it rather than asking again:

Written On a target that has it On a target that does not
IClock clock the capability an error naming the capability and the component
IClock? clock the capability null, for the component to handle

A non nullable parameter is the component promising it cannot work without one, so a null handed to it fails later, inside code that never mentions capabilities, on the one target where the screen is broken. The message names which capability, which component, and both ways out: register it with the host, or declare the parameter nullable. Both targets say it, or the browser would be the lenient one and the bug would exist only there.

A file with nullable disabled gives no signal either way, and keeps the meaning it always had.

Handing one over in a test

Since 0.2.0-preview.33

using var _ = CapabilityScope.With<IClock>(fake);

var section = new PairLoop();
section.NotifyMounted();

One capability, over whatever is already in force, put back on dispose. Nesting composes. The ceremony it replaces was a resolver written by hand, which answered null to every OTHER capability the component might ask for and had to be undone in a finally that a test could forget.

Rendering on the server

Since 0.2.0-preview.13

The same page has to be server-renderable, and the server has no camera. Every capability resolves there to an ABSENT realization: it reports itself unavailable and hands back nothing.

Without it the page could not be constructed at all: no constructor the container could satisfy, and the request ended in a 500. The one page that does something was the one page a crawler never saw, and the visitor waited for JavaScript just to be told the page exists.

Absent, deliberately, and not simulated. There is no camera in a datacenter and the visitor's localStorage is on the visitor's machine, so a server-side fake would be worse than the failure: the page would render one thing, the browser would hydrate another, and the mismatch would be blamed on the reconciler. The page takes the availability branch it already has to have, and the client boot's real capability replaces the fallback on the first client render.

INetworkStatus is the exception, and the exception is the point: reporting offline would bake the offline banner into the markup every crawler and every first paint sees, for visitors who just proved they are online by fetching the page. It answers online.

Registered with TryAdd, so an app with a genuine server-side answer (a storage backed by the user's session) registers its own and wins.

Clone this wiki locally