OS permissions (headless capability crate)

Declare before you ask

Every mobile OS requires a build-time declaration in addition to the runtime request, and the failure modes are not symmetric:

  • iOS and macOS terminate the process when it touches a gated API without the matching NS…UsageDescription key. There is no exception to catch: TCC terminates the process.
  • Android reports an undeclared permission as [Status::Restricted]: a request returns denied in the same frame, with no dialog, and Settings offers nothing.
  • HarmonyOS refuses the request outright.

So declare what you use in Day.toml, and day build generates each platform’s manifest entry:

[permissions]
camera               = "Attach photos to your notes."
location-when-in-use = "Show stations near you."
notifications        = true          # needs no reason on any platform

The reason text is what the OS shows the user in its own prompt. day lint flags a permission your code requests but Day.toml doesn’t declare, so this is caught in CI rather than on a device.

Localized reasons

The inline text above is the single-language shortcut. An app with more than one locale keeps each reason in its catalogs instead, as a permission_<name> message beside the rest of its copy, so translators see it where they work and the prompt speaks the user’s language:

[permissions]
camera = true                 # declared; the text is the catalog's
location-when-in-use = true

[permissions.raw]
ios = { NSBluetoothAlwaysUsageDescription = true }
# resource/locales/en/app.ftl
permission_camera = Attach photos to your notes.
permission_location_when_in_use = Show stations near you.
permission_NSBluetoothAlwaysUsageDescription = Find your bike's lock.

The message id is permission_ plus the portable name with every non-alphanumeric character as _, or plus the native key itself for a raw entry. The catalog’s message wins over inline text in the default locale; inline text still fills the default locale when the catalog has no message, so a single-locale app never has to move anything. day localize add copies the messages into a new locale with the rest of the catalog, and day metadata --json reports every declaration’s reasons by locale for tooling such as the project site.

Authoring

use day_part_permissions::{Permission, Status, request, status};

if status(Permission::Camera) != Status::Granted {
    request(Permission::Camera, |s| println!("camera: {s}"));
}
FunctionAnswers
gate(perm) -> Gatewhether this target gates the capability at all
status(perm) -> Statuswhat the OS will do if you use it now (never blocks)
status_async / status_futurethe same, but authoritative where the platform is async
can_prompt(perm) -> boolwhether request would show a dialog
should_show_rationale(perm)Android’s “explain first” signal; false elsewhere
request / request_futureask the OS
request_many / request_many_futureask for several in one prompt sequence
open_settings(perm) -> boolthe remedy when the answer is already final

The crate has no cargo features: platform selection is purely #[cfg(target_os)], since consent depends on the OS, not on which widget toolkit is in use. parts/day-part-permissions/examples/permissions.rs is a plain main that uses it with no Day framework at all.

Two questions, two vocabularies

gate() answers a structural question and status() a live one, and keeping them apart lets an ungated platform answer accurately:

Gatemeaning
Promptsthe OS keeps a consent record and can show a dialog
Ungatedthe capability exists and nothing gates it (desktop Linux, Windows)
Absentno such capability here at all
Statusmeaning
Grantedgo ahead
Promptnobody has decided; request will show a dialog
Deniedthe user said no; can_prompt says whether asking again can help
Restrictedpolicy forbids it, or it is missing from the merged manifest; neither asking nor Settings helps
UnsupportedGate::Absent
Unknownthe platform answers only asynchronously and hasn’t yet (web, and Apple notifications)

On desktop Linux the camera has no permission gate, so status answers Granted: an app asking “may I use the camera?” should proceed, and the real failure belongs at open("/dev/video0"). The structural fact moves to gate() == Ungated. Two invariants are unit-tested: Ungated ⟹ Granted, and Absent ⟹ Unsupported.

Granted is not a promise that the hardware exists. A laptop with no camera still answers Granted, because no permission stands in the way; ask the capability’s own part (e.g. day_part_sensors::is_available) about hardware.

Reasons are not a runtime parameter

request(perm, reason) is the natural API guess, but no platform accepts a reason at request time. iOS and macOS read NS…UsageDescription from Info.plist; requestPermissions(String[], int) and requestPermissionsFromUser(context, string[]) take no text, and neither does getUserMedia or Notification.requestPermission. The reason therefore lives in the declaration, where it reaches the OS, and the runtime hands your app the two bits it needs to draw its own priming UI: should_show_rationale and can_prompt.

As a consequence, no user-facing string crosses this crate’s boundary, so the layering rule that keeps IntoText/LocalizedText out of parts (docs/extending.md §4) never has to be worked around.

Per-platform native realization

OScheckrequestdependency
iOSCLLocationManager, AVCaptureDevice, PHPhotoLibrary, UNUserNotificationCenter (async-only), CMMotionActivityManagerthe matching block-based request…objc2 + block2, [package.metadata.day.ios].frameworks
macOSthe same TCC APIs where they exist; no CoreMotionsameshared apple.rs
AndroidContext.checkSelfPermission + getPackageInfo(GET_PERMISSIONS)requestPermissions from a headless Fragmentday-android + the crate’s own Java shim
HarmonyOSOH_AT_CheckSelfPermissionrequestPermissionsFromUser, through the host page’s registerPermissions seam in day-arkui-sys (reached by dlsym, so the part links no toolkit)raw FFI
Webnavigator.permissions.query + a live change cache; Notification.permission is syncthe per-API callthe day-dom shim
Linux / Windowsconstantsresolves immediately

Two platform facts apply everywhere:

  • request is callback-and-future only; there is no blocking form. The OS prompt is drawn by the very thread a blocking call would park, so it would deadlock by construction on every platform.
  • Dropping a StatusFuture does not dismiss the prompt. No platform can dismiss its own permission dialog programmatically. Dropping stops you listening; the user’s answer is still recorded, so the next status() is correct. Aborting a day::task that awaits one therefore leaves the dialog on screen.

Android cannot tell “never asked” from “permanently denied”

It cannot without app-side state, and Day keeps none. A denied-but-declared permission with no rationale flag is reported as Prompt either way. That is safe (asking after a permanent refusal shows no dialog and resolves Denied immediately), but if your app needs the distinction, record it yourself when you call request:

day_part_permissions::request(perm, move |s| {
    day::prefs::set("asked.camera", "1");
    // …
});

macOS: the desktop dev loop cannot exercise permissions

day launch -p macos-appkit runs the bare binary, not a bundle. TCC reads usage descriptions from a bundle’s Info.plist, so an unbundled process is denied (or killed) regardless of what Day.toml says. Only day pack -p macos-appkit produces a bundle that can be granted anything. The crate guards every UNUserNotificationCenter call behind a bundle check for the same reason: touching it unbundled aborts the process.

What the declaration pipeline generates

portable nameAndroidiOS Info.plistmacOSHarmonyOS
location-when-in-useACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATIONNSLocationWhenInUseUsageDescription+ NSLocationUsageDescriptionAPPROXIMATELY_LOCATION + LOCATION
location-always+ ACCESS_BACKGROUND_LOCATIONthat key and the when-in-use one (Apple suppresses the prompt without both)+ NSLocationUsageDescription+ LOCATION_IN_BACKGROUND
cameraCAMERANSCameraUsageDescriptionsameohos.permission.CAMERA
microphoneRECORD_AUDIONSMicrophoneUsageDescriptionsameohos.permission.MICROPHONE
notificationsPOST_NOTIFICATIONSnonenonenone (a runtime call)
photosREAD_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE capped at maxSdkVersion=32NSPhotoLibraryUsageDescriptionsameREAD_IMAGEVIDEO
motionACTIVITY_RECOGNITIONNSMotionUsageDescriptionnone (CoreMotion is iOS-only)ACTIVITY_MOTION

ohos.permission.READ_IMAGEVIDEO is a system_basic permission, which an app signed at the default normal level cannot hold. Prefer PhotoViewPicker, which needs no permission at all.

The table lives in day_build::permissions, one source shared by the CLI’s generators and this crate’s runtime, with a parity test pinning the Rust variant names so day lint can map a source reference back to a declaration.

For anything outside the portable seven, use the raw tables:

[permissions.raw]
android = ["android.permission.READ_CONTACTS"]
ios     = { NSContactsUsageDescription = "Find friends who already use Day." }
ohos    = [{ name = "ohos.permission.READ_CONTACTS", reason = "Find friends.", when = "inuse" }]

A library can declare the machine-facing half itself ([package.metadata.day.permissions] uses = ["camera"]) but never the reason, which is app copy. A contribution with no reason in the app’s Day.toml is a hard build error on iOS and HarmonyOS, naming the crate and the lines to paste.

Where each file is written, and what Day owns in it

  • Androidbuild/day/android/day-pieces-manifest.xml, gitignored and regenerated, merged by AGP. That filename is a compatibility surface: it is baked into every scaffold day new has generated, and a source set has one manifest slot, so it is widened, never moved.
  • iOS/macOS — the checked-in platform/ios/Runner/Info.plist, edited in place. Day owns exactly the keys in the table above plus your [permissions.raw] keys; every other byte is preserved, so the diff shows only what changed, and a hand-added key Day doesn’t model is never touched. Two consecutive builds produce a byte-identical file. This is an exception to “aggregation never mutates the scaffolds” (DESIGN §15.2), because the alternative broke ⌘R in Xcode. The plist carries the default locale’s text; the translations go to InfoPlist.xcstrings beside it, the string catalog Xcode 15+ reads localized Info.plist values from, one stringUnit per key and locale in Xcode’s own locale spelling (zh-Hans). The scaffold ships an empty catalog already wired into the target; an older project gets the file and its four project entries the first time a build has a translation to write. Regenerated from the same plan as the plist and byte-stable, so open it in Xcode to read, not to edit.
  • HarmonyOS — Day copies the native host into build/day/harmony/project/ and merges permissions into that copy’s entry/src/main/module.json5. The referenced day_perm_reason_* strings go into the staged resources/base/element/string.json and per-locale files such as resources/fr/element/string.json and resources/zh_CN/element/string.json. Hand-written strings survive the merge; obsolete generated translations are removed. The source host under platform/harmony/ stays unchanged, so adding a locale requires no generated native files in git. day prepare prepares these files too, and day open -p harmony-arkui opens the staged project.

day lint

codefires when
day::lint::undeclared-permissioncode requests Permission::X that Day.toml doesn’t declare
day::lint::missing-reasona declared permission that needs a reason has none — or has inline text only while another locale’s catalog lacks its permission_<name> message
day::lint::duplicate-reasona reason given both inline and in the default catalog (the catalog’s ships; the other can drift)
day::lint::unused-permissiondeclared, referenced by nothing (a warning — over-declaring gets apps rejected)
day::lint::stale-manifestthe checked-in Info.plist or InfoPlist.xcstrings disagrees with Day.toml and the catalogs; run day build -p ios-uikit

What it shows about the extension system

The crate registers nothing in any RENDERERS slice. Its Android half is bundled with it and folded into the app’s Gradle build by [package.metadata.day.android], with no edits to any core Day crate, including the permission-result callback, which lives in a headless Fragment the crate attaches itself rather than in day-android’s DayActivity. Its permissions = [] entry is empty and must stay that way, because a permissions crate must never force a permission into an app’s manifest. See extending.md.