Skip to main content

day_build/
permissions.rs

1//! The portable-permission → native-declaration table (docs/permissions.md).
2//!
3//! Two consumers need exactly this knowledge and must never disagree about it:
4//!
5//! - `day-cli` turns a `[permissions]` table in `Day.toml` into `<uses-permission>` entries, iOS and
6//!   macOS `Info.plist` usage-description keys, and HarmonyOS `module.json5` `requestPermissions`.
7//! - `day-part-permissions` asks the OS about the same permissions at runtime.
8//!
9//! It lives here rather than in a new crate because `day-build` is already published, already a
10//! `day-cli` dependency, and already carries the tree's other CLI-and-runtime shared mapping (the
11//! resource name → identifier table), for the same reason: a generated declaration must never drift
12//! from the constant the app's code names.
13//!
14//! Two rows break any naive version of this table, so they are worth stating up front:
15//! **notifications** needs an Android permission but NO iOS/macOS plist key and no HarmonyOS entry,
16//! and **photos** needs three Android permissions, one of them version-capped.
17
18/// An Android permission id, plus the API level after which it must not be requested.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct AndroidPermission {
21    pub name: &'static str,
22    /// Emitted as `android:maxSdkVersion`. Only the legacy storage permission needs it: from API 33
23    /// the granular `READ_MEDIA_*` permissions replace it, and leaving it uncapped makes stores flag
24    /// the app for requesting broad storage access it no longer uses.
25    pub max_sdk: Option<u32>,
26}
27
28/// When a HarmonyOS permission is used, which its `module.json5` entry must declare.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum OhosScene {
31    InUse,
32    Always,
33}
34
35impl OhosScene {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            OhosScene::InUse => "inuse",
39            OhosScene::Always => "always",
40        }
41    }
42}
43
44/// A HarmonyOS permission name and the scene it is requested for.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct OhosPermission {
47    pub name: &'static str,
48    pub when: OhosScene,
49}
50
51/// One portable permission, and everything each platform needs declared for it.
52#[derive(Clone, Copy, Debug)]
53pub struct PermissionSpec {
54    /// The kebab-case name written in `Day.toml`'s `[permissions]` table.
55    pub name: &'static str,
56    /// The `day_part_permissions::Permission` variant spelling, so `day lint` can map a source
57    /// reference back to a declaration.
58    pub variant: &'static str,
59    pub android: &'static [AndroidPermission],
60    pub ios: &'static [&'static str],
61    pub macos: &'static [&'static str],
62    pub ohos: &'static [OhosPermission],
63    /// Whether a user-facing reason is required. False only for notifications, which no platform
64    /// asks a reason for.
65    pub needs_reason: bool,
66}
67
68const fn android(name: &'static str) -> AndroidPermission {
69    AndroidPermission {
70        name,
71        max_sdk: None,
72    }
73}
74
75const fn ohos(name: &'static str) -> OhosPermission {
76    OhosPermission {
77        name,
78        when: OhosScene::InUse,
79    }
80}
81
82/// Every portable permission Day knows how to declare.
83pub const ALL: &[PermissionSpec] = &[
84    PermissionSpec {
85        name: "location-when-in-use",
86        variant: "Location",
87        android: &[
88            android("android.permission.ACCESS_FINE_LOCATION"),
89            android("android.permission.ACCESS_COARSE_LOCATION"),
90        ],
91        ios: &["NSLocationWhenInUseUsageDescription"],
92        // macOS also wants the legacy key: some frameworks still read it.
93        macos: &[
94            "NSLocationWhenInUseUsageDescription",
95            "NSLocationUsageDescription",
96        ],
97        ohos: &[
98            ohos("ohos.permission.APPROXIMATELY_LOCATION"),
99            ohos("ohos.permission.LOCATION"),
100        ],
101        needs_reason: true,
102    },
103    PermissionSpec {
104        name: "location-always",
105        variant: "LocationAlways",
106        android: &[
107            android("android.permission.ACCESS_FINE_LOCATION"),
108            android("android.permission.ACCESS_COARSE_LOCATION"),
109            android("android.permission.ACCESS_BACKGROUND_LOCATION"),
110        ],
111        // Apple requires BOTH keys: a plist carrying only the Always key suppresses the prompt.
112        ios: &[
113            "NSLocationAlwaysAndWhenInUseUsageDescription",
114            "NSLocationWhenInUseUsageDescription",
115        ],
116        macos: &[
117            "NSLocationAlwaysAndWhenInUseUsageDescription",
118            "NSLocationWhenInUseUsageDescription",
119            "NSLocationUsageDescription",
120        ],
121        ohos: &[
122            ohos("ohos.permission.APPROXIMATELY_LOCATION"),
123            ohos("ohos.permission.LOCATION"),
124            OhosPermission {
125                name: "ohos.permission.LOCATION_IN_BACKGROUND",
126                when: OhosScene::Always,
127            },
128        ],
129        needs_reason: true,
130    },
131    PermissionSpec {
132        name: "camera",
133        variant: "Camera",
134        android: &[android("android.permission.CAMERA")],
135        ios: &["NSCameraUsageDescription"],
136        macos: &["NSCameraUsageDescription"],
137        ohos: &[ohos("ohos.permission.CAMERA")],
138        needs_reason: true,
139    },
140    PermissionSpec {
141        name: "microphone",
142        variant: "Microphone",
143        android: &[android("android.permission.RECORD_AUDIO")],
144        ios: &["NSMicrophoneUsageDescription"],
145        macos: &["NSMicrophoneUsageDescription"],
146        ohos: &[ohos("ohos.permission.MICROPHONE")],
147        needs_reason: true,
148    },
149    PermissionSpec {
150        name: "notifications",
151        variant: "Notifications",
152        // Ignored below API 33, so it is declared unconditionally.
153        android: &[android("android.permission.POST_NOTIFICATIONS")],
154        // Apple asks for notification permission at runtime with no plist key, and HarmonyOS gates
155        // it through a runtime `requestEnableNotification` call rather than the manifest.
156        ios: &[],
157        macos: &[],
158        ohos: &[],
159        needs_reason: false,
160    },
161    PermissionSpec {
162        name: "photos",
163        variant: "Photos",
164        android: &[
165            AndroidPermission {
166                name: "android.permission.READ_MEDIA_IMAGES",
167                max_sdk: None,
168            },
169            AndroidPermission {
170                name: "android.permission.READ_MEDIA_VIDEO",
171                max_sdk: None,
172            },
173            AndroidPermission {
174                name: "android.permission.READ_EXTERNAL_STORAGE",
175                max_sdk: Some(32),
176            },
177        ],
178        ios: &["NSPhotoLibraryUsageDescription"],
179        macos: &["NSPhotoLibraryUsageDescription"],
180        // NOTE: `READ_IMAGEVIDEO` is `system_basic` apl, which an app signed at `normal` cannot
181        // hold — see OHOS_PHOTOS_APL_NOTE. The picker needs no permission at all.
182        ohos: &[ohos("ohos.permission.READ_IMAGEVIDEO")],
183        needs_reason: true,
184    },
185    PermissionSpec {
186        name: "motion",
187        variant: "Motion",
188        android: &[android("android.permission.ACTIVITY_RECOGNITION")],
189        ios: &["NSMotionUsageDescription"],
190        // CoreMotion's activity APIs are iOS-only; macOS has nothing to declare.
191        macos: &[],
192        ohos: &[ohos("ohos.permission.ACTIVITY_MOTION")],
193        needs_reason: true,
194    },
195];
196
197/// The warning both the CLI and the docs use for HarmonyOS photo access, kept in one place so they
198/// cannot drift.
199pub const OHOS_PHOTOS_APL_NOTE: &str = "ohos.permission.READ_IMAGEVIDEO is a system_basic permission, which an app signed at the \
200     default `normal` level cannot be granted. Prefer PhotoViewPicker, which needs no permission.";
201
202/// Look a permission up by its `Day.toml` name.
203pub fn find(name: &str) -> Option<&'static PermissionSpec> {
204    ALL.iter().find(|s| s.name == name)
205}
206
207/// Look a permission up by its Rust variant spelling (`day lint`'s source scan).
208pub fn find_variant(variant: &str) -> Option<&'static PermissionSpec> {
209    ALL.iter().find(|s| s.variant == variant)
210}
211
212/// Every valid `[permissions]` key, for error messages.
213pub fn names() -> Vec<&'static str> {
214    ALL.iter().map(|s| s.name).collect()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn names_are_unique_kebab_and_round_trip() {
223        let mut seen = std::collections::BTreeSet::new();
224        for spec in ALL {
225            assert!(seen.insert(spec.name), "duplicate name {}", spec.name);
226            assert!(
227                spec.name
228                    .chars()
229                    .all(|c| c.is_ascii_lowercase() || c == '-'),
230                "{} is not kebab-case",
231                spec.name
232            );
233            assert_eq!(find(spec.name).map(|s| s.variant), Some(spec.variant));
234            assert_eq!(find_variant(spec.variant).map(|s| s.name), Some(spec.name));
235        }
236        assert_eq!(names().len(), ALL.len());
237        assert!(find("nonsense").is_none());
238    }
239
240    /// A plist carrying only `NSLocationAlwaysAndWhenInUseUsageDescription` suppresses the prompt —
241    /// Apple requires the when-in-use key alongside it.
242    #[test]
243    fn location_always_also_declares_when_in_use() {
244        let spec = find("location-always").expect("location-always");
245        assert!(spec.ios.contains(&"NSLocationWhenInUseUsageDescription"));
246        assert!(spec.macos.contains(&"NSLocationWhenInUseUsageDescription"));
247    }
248
249    /// The row a naive table gets wrong in the other direction: an Android permission, but nothing
250    /// to declare on Apple or HarmonyOS, and no reason anywhere.
251    #[test]
252    fn notifications_declares_android_only() {
253        let spec = find("notifications").expect("notifications");
254        assert_eq!(spec.android.len(), 1);
255        assert!(spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty());
256        assert!(!spec.needs_reason);
257    }
258
259    /// Legacy storage must be capped at 32 or stores flag the app for over-broad access.
260    #[test]
261    fn photos_caps_legacy_storage() {
262        let spec = find("photos").expect("photos");
263        let legacy = spec
264            .android
265            .iter()
266            .find(|p| p.name.ends_with("READ_EXTERNAL_STORAGE"))
267            .expect("legacy storage permission");
268        assert_eq!(legacy.max_sdk, Some(32));
269        // The granular replacements must NOT be capped.
270        for p in spec
271            .android
272            .iter()
273            .filter(|p| p.name.contains("READ_MEDIA"))
274        {
275            assert_eq!(p.max_sdk, None, "{} should not be capped", p.name);
276        }
277    }
278
279    /// Every permission that needs a reason must have somewhere to put one.
280    #[test]
281    fn reasons_have_a_destination() {
282        for spec in ALL {
283            if spec.needs_reason {
284                assert!(
285                    !spec.ios.is_empty() || !spec.ohos.is_empty(),
286                    "{} needs a reason but no platform consumes it",
287                    spec.name
288                );
289            } else {
290                assert!(
291                    spec.ios.is_empty() && spec.macos.is_empty() && spec.ohos.is_empty(),
292                    "{} needs no reason, so it must declare no reason-carrying key",
293                    spec.name
294                );
295            }
296        }
297    }
298
299    /// macOS has no CoreMotion activity API, so it has nothing to declare for motion.
300    #[test]
301    fn motion_is_ios_only_on_apple() {
302        let spec = find("motion").expect("motion");
303        assert!(!spec.ios.is_empty());
304        assert!(spec.macos.is_empty());
305    }
306}