Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/adr/ADR-043-stereo-camera-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,19 @@ exactly so that knowledge lives in one vendor DLL and nowhere else.
development switch below the sharing-off checks — the R1 text had it as a stand-in to be
deleted.

## Amendment 2 (2026-10-06) — delegation never outranks a refusal

As built in R3 (PR #1819) a registered consent-delegating client was allowed at the fourth step,
**before** the OS camera privacy switch and before a Deny the user had stored for that executable
— so once an installer registered a browser, it would get the camera with the OS switch off and
a stored Deny silently ignored. That contradicted §B.3 of the roadmap and the pre-R3 spec, where
OS consent applied to every consumer and delegation replaced only the DisplayXR prompt. No rationale
for the order was recorded, and the plausible one ("the browser enforces the OS switch itself")
does not hold: the service, not the browser, opens the camera, so the OS never sees the browser as
a camera user.

The order is now: sharing off / `DXR_STEREO_CAMERA=0` → (dev override) → no identity → **OS
camera switch** → **stored Deny** → delegating client → stored Allow → Allow once → prompt
(spec §7.1 is normative). Delegation means exactly "no runtime prompt and no stored per-app
decision needed". `displayxr-cli camera trust` over a stored Deny clears it and says so (the
user's newer explicit word); an installer's registration never touches the user's store.
28 changes: 20 additions & 8 deletions docs/guides/stereo-camera-consent.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,28 @@ XrInstanceCreateInfo ci = {XR_TYPE_INSTANCE_CREATE_INFO, &consumer, /* … XR_DX

The service looks at the **OS-derived executable path** of your process (never anything you
send) and walks this list; the first hit decides. The same check runs for
`xrGetStereoCameraCalibrationDXR` (calibration identifies the device).
`xrGetStereoCameraCalibrationDXR` (calibration identifies the device). The normative statement
is the table in [spec §7.1](../specs/extensions/XR_DXR_stereo_camera.md#71-authorisation-at-xrstartstereocamerastreamdxr-and-xrgetstereocameracalibrationdxr);
this is the same order in developer terms.

1. **Sharing off** — the user's tray / menu-bar toggle *"Share the 3D camera with apps"*, or
`DXR_STEREO_CAMERA=0` in the service's environment → `XR_ERROR_STEREO_CAMERA_DISABLED_DXR`.
(You also enumerate zero cameras.)
2. `DXR_STEREO_CAMERA_DEV_ALLOW=1` in the **service's** environment → allowed. A development
override; the service logs one WARN per run. Never on a user's machine.
3. Your executable path could not be verified → `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR`.
4. You are a **registered delegating client** → allowed, no prompt, nothing stored.
5. **Windows:** the OS camera privacy switch denies you (Settings → Privacy → Camera: globally,
for desktop apps, or for your app) → `XR_ERROR_PERMISSION_INSUFFICIENT`.
6. A **stored decision** for your executable (the user answered before, or an admin pre-seeded
it) → allowed, or `CONSENT_REFUSED`.
7. The user chose **Allow once** earlier for this very process → allowed.
8. The **prompt**: the service's tray (Windows) / menu-bar item (macOS) shows
4. **Windows:** the OS camera privacy switch denies you (Settings → Privacy → Camera: globally,
for desktop apps, or for your app) → `XR_ERROR_PERMISSION_INSUFFICIENT`. This applies to
delegating clients too: the service opens the camera, so the OS never sees you as its user and
only the runtime can honour the switch.
5. A stored **Deny** for your executable (the user answered Deny before, or ran
`displayxr-cli camera deny`) → `CONSENT_REFUSED`, even if you are a registered delegating
client.
6. You are a **registered delegating client** → allowed, no prompt, nothing stored.
7. A stored **Allow** for your executable (the user answered before, or an admin pre-seeded it)
→ allowed.
8. The user chose **Allow once** earlier for this very process → allowed.
9. The **prompt**: the service's tray (Windows) / menu-bar item (macOS) shows

> **"<your application name>" wants to use the 3D camera**
> *your-app.exe (pid 1234) will receive frames from the display's stereo camera. You can stop
Expand Down Expand Up @@ -99,6 +106,11 @@ should not ask a second time per page. Register the browser's **executable path*
`{"delegating": ["/full/path/to/browser", …]}`.
- **Per user / development:** `displayxr-cli camera trust <exe>` (or `--self`), which writes the
user-level list (`HKCU\…\CameraConsent\Delegating`, `~/.config/…/camera_consent.json`).
If the user had stored a Deny for that executable, `trust` clears it and says so; an
installer's registration never does — a user's Deny beats it.

Registration means **no runtime prompt and no stored decision needed** — nothing more. Sharing
off, the OS camera switch and a stored Deny still refuse you, each with its own result (§2).

Obligations that come with it: prompt the user yourself before opening a stream, show your own
in-use indicator, stop the stream when your page stops capturing, and never expose raw
Expand Down
15 changes: 10 additions & 5 deletions docs/roadmap/stereo-camera-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ process across milestones):
signer) as a **consent-delegating** client of the runtime (spec §7.1), so the runtime does not
prompt a second time per page. An unsigned/dev build is not delegating: the runtime prompts once
for it from the tray.
- **OS camera privacy:** if Windows camera access is off globally or for desktop apps, the runtime
refuses (`PERMISSION_INSUFFICIENT`) and the device reports an error — the same outcome a normal
webcam would give, so users are not surprised by a camera that ignores the OS switch.
- **OS camera privacy:** if Windows camera access is off globally, for desktop apps or for the
browser, the runtime refuses (`PERMISSION_INSUFFICIENT`) **even though the browser is
delegating** and the device reports an error — the same outcome a normal webcam would give, so
users are not surprised by a camera that ignores the OS switch. Likewise a Deny the user stored
for the browser's executable, and the runtime's sharing switch, refuse it. Delegation only
removes the runtime's own prompt (spec §7.1 states the order).
- **Runtime indicator** (tray badge naming `chrome.exe`) in addition to Chromium's; runtime kill
switch hides the device entirely.
- **Fingerprinting:** `device_id` is Chromium's per-origin hash of `dxr-stereo:<persistentId>`
Expand Down Expand Up @@ -305,8 +308,10 @@ where:
**B1 migration:** the browser's video-capture utility must chain the struct instead of
enabling `XR_DXR_weave` to be classed as the browser (the weave-derived PRESENT_OWNER claim
is outranked by the declaration anyway); its other processes keep the sibling rule.
- **Consent policy + store** (`u_camera_consent.{h,c}`, `u_camera_consent_store.c`): the eight-
step decision tree of spec §7.1 over two injected vtables (store, environment), the real store
- **Consent policy + store** (`u_camera_consent.{h,c}`, `u_camera_consent_store.c`): the
decision tree of spec §7.1 (since the follow-up to PR #1819: kill switches, then the OS camera
switch, then a stored Deny refuse **before** delegation allows — as built in #1819 a delegating
client was allowed ahead of both) over two injected vtables (store, environment), the real store
(Windows registry under `HKCU\Software\DisplayXR\CameraConsent`, POSIX
`camera_consent.json` mode 0600 in the user config dir, plus the installer-written delegating
lists), the Windows `ConsentStore\webcam` read, and the keyed `persistentId`
Expand Down
28 changes: 22 additions & 6 deletions docs/specs/extensions/XR_DXR_stereo_camera.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,11 +356,27 @@ at a time (so two apps never race two prompts):
| 1 | Sharing off: `DXR_STEREO_CAMERA=0` in the service environment, or the user's **"Share the 3D camera with apps"** toggle (tray / menu bar, persisted in the store) | `XR_ERROR_STEREO_CAMERA_DISABLED_DXR` (and zero cameras enumerated) |
| 2 | `DXR_STEREO_CAMERA_DEV_ALLOW=1` in the service environment — development override, one WARN per service run; **never** on a user's machine | allowed |
| 3 | The peer executable could not be verified (`""`) | `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` |
| 4 | The executable is a registered **consent-delegating** client (a browser that shows its own per-origin prompt + in-use indicator): machine list written by its installer (`HKLM\Software\DisplayXR\CameraConsent\Delegating`, `/etc/displayxr/camera-delegating.json`, `/Library/Application Support/DisplayXR/camera-delegating.json`) or the user list (`displayxr-cli camera trust <exe>`) | allowed, no prompt, no store entry; never RAW (§7.5) |
| 5 | The OS camera privacy switch denies this executable (Windows `CapabilityAccessManager\ConsentStore\webcam`: machine policy, user global, desktop-app class, per-app entry; no equivalent is readable on macOS / Linux) | `XR_ERROR_PERMISSION_INSUFFICIENT` |
| 6 | The stored per-app decision (`HKCU\Software\DisplayXR\CameraConsent\Apps\<path>`, `camera_consent.json` `apps`) | Allow → allowed; Deny → `CONSENT_REFUSED` |
| 7 | "Allow once" already granted to this same process (executable + pid) | allowed |
| 8 | The **tray prompt**: *"<app> wants to use the 3D camera — Allow / Allow once / Deny"* (Windows: a top-most dialog from the tray thread; macOS: a floating panel from the menu-bar item — never a modal that would park the service's run loop). **Allow** and **Deny** are written to the store; **Allow once** is remembered for the process. The call blocks for up to 60 s; unanswered / dismissed, or no prompt available (`DXR_STEREO_CAMERA_PROMPT=0`, headless Linux service) | `CONSENT_REFUSED` — retryable, asked again next time |
| 4 | The OS camera privacy switch denies this executable (Windows `CapabilityAccessManager\ConsentStore\webcam`: machine policy, user global, desktop-app class, per-app entry; no equivalent is readable on macOS / Linux) — **for every client, delegating ones included** | `XR_ERROR_PERMISSION_INSUFFICIENT` |
| 5 | A stored per-app **Deny** (`HKCU\Software\DisplayXR\CameraConsent\Apps\<path>` = 2, `camera_consent.json` `apps`) — it beats a delegating registration, an installer's included | `CONSENT_REFUSED` |
| 6 | The executable is a registered **consent-delegating** client (a browser that shows its own per-origin prompt + in-use indicator): machine list written by its installer (`HKLM\Software\DisplayXR\CameraConsent\Delegating`, `/etc/displayxr/camera-delegating.json`, `/Library/Application Support/DisplayXR/camera-delegating.json`) or the user list (`displayxr-cli camera trust <exe>`) | allowed, no prompt, no store entry; never RAW (§7.5) |
| 7 | A stored per-app **Allow** | allowed |
| 8 | "Allow once" already granted to this same process (executable + pid) | allowed |
| 9 | The **tray prompt**: *"<app> wants to use the 3D camera — Allow / Allow once / Deny"* (Windows: a top-most dialog from the tray thread; macOS: a floating panel from the menu-bar item — never a modal that would park the service's run loop). **Allow** and **Deny** are written to the store; **Allow once** is remembered for the process. The call blocks for up to 60 s; unanswered / dismissed, or no prompt available (`DXR_STEREO_CAMERA_PROMPT=0`, headless Linux service) | `CONSENT_REFUSED` — retryable, asked again next time |

**Delegation means exactly "no runtime prompt and no stored per-app decision needed" — nothing
more.** It never outranks a refusal: the kill switches (row 1), the OS switch (row 4) and a stored
Deny (row 5) apply to a delegating client exactly as to any other app. The OS switch matters most
for it: the service, not the client, opens the camera, so the OS never sees the browser as a
camera user and nothing else would enforce the user's switch. Each refusal keeps its own result
(`DISABLED` / `PERMISSION_INSUFFICIENT` / `CONSENT_REFUSED`), so a browser can map them (§8).

`displayxr-cli camera trust <exe>` over a stored Deny for the same executable **clears the Deny**
and says so — `trust` is the user's newer, explicit word on that executable, as `allow` replacing
a Deny is. An installer's machine-wide registration never touches the user's store: there the
Deny keeps winning. `camera deny` on a delegating executable notes that the Deny wins.

This table is the one statement of the order; `u_camera_consent.h`, the developer guide and the
roadmap refer to it.

A refused stream stays created and may be started again (e.g. after `displayxr-cli camera allow`).
The policy is pure (two injected vtables) and unit-tested with fakes in `tests_camera_consent`.
Expand Down Expand Up @@ -435,7 +451,7 @@ The service queues, per connection (16 deep, oldest dropped), and the client dra
|---|---|
| `XR_STEREO_CAMERA_FRAME_NOT_READY_DXR` (success) | no frame newer than the last acquired |
| `XR_ERROR_VALIDATION_FAILURE` | struct out of contract (unknown camera, format/transport not in the supported bits, RECTIFIED on an uncalibrated camera) |
| `XR_ERROR_PERMISSION_INSUFFICIENT` | the OS camera privacy switch denies the app (§7.1 step 5); RAW asked by a browser / delegating client; a client class that may not use cameras (or `xrCreateSession` from a `CAMERA_CONSUMER`) |
| `XR_ERROR_PERMISSION_INSUFFICIENT` | the OS camera privacy switch denies the app (§7.1 row 4, delegating clients included); RAW asked by a browser / delegating client; a client class that may not use cameras (or `xrCreateSession` from a `CAMERA_CONSUMER`) |
| `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` | §7.1: the user / the stored decision refused, the prompt was unanswered or unavailable; retryable after consent. A browser maps it to `NotAllowedError` |
| `XR_ERROR_STEREO_CAMERA_DISABLED_DXR` | §7.4: sharing is off (user toggle or `DXR_STEREO_CAMERA=0`) |
| `XR_ERROR_STEREO_CAMERA_BUSY_DXR` | the plug-in could not open the source and its retry is pending; retry later. A browser maps it to `NotReadableError` |
Expand Down
114 changes: 95 additions & 19 deletions src/xrt/auxiliary/util/u_camera_consent.c
Original file line number Diff line number Diff line change
Expand Up @@ -164,35 +164,43 @@ u_camera_consent_evaluate(struct u_camera_consent *c,
decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_NO_IDENTITY);
return;
}
// 4. a registered delegating client prompts per origin itself.
if (st != NULL && st->is_delegating != NULL && st->is_delegating(sc, exe)) {
out->delegating = true;
decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING);
return;
}
// 5. the OS switch: a camera that ignores it would surprise the user.
// A fact about the executable, reported whatever the verdict (the service
// keys the RAW refusal on it). It only DECIDES at step 6.
out->delegating = st != NULL && st->is_delegating != NULL && st->is_delegating(sc, exe);
// 4. the OS switch, for every client, delegating ones included: the service
// opens the camera, not the client, so the OS never sees this consumer
// and nobody else will enforce the user's switch for it.
if (c->env != NULL && c->env->os_camera_allowed != NULL && !c->env->os_camera_allowed(c->env_ctx, exe)) {
decide(out, U_CAMERA_CONSENT_OS_DENIED, U_CAMERA_CONSENT_WHY_OS_DENIED);
return;
}
// 6. the stored decision.
// 5. a stored Deny is the user's explicit "no" for this executable and
// beats any registration (an installer's included).
enum u_camera_consent_stored stored = U_CAMERA_CONSENT_STORED_NONE;
if (st != NULL && st->get != NULL && st->get(sc, exe, &stored)) {
if (stored == U_CAMERA_CONSENT_STORED_ALLOW) {
decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_STORED_ALLOW);
return;
}
if (stored == U_CAMERA_CONSENT_STORED_DENY) {
decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_STORED_DENY);
return;
}
if (st == NULL || st->get == NULL || !st->get(sc, exe, &stored)) {
stored = U_CAMERA_CONSENT_STORED_NONE;
}
if (stored == U_CAMERA_CONSENT_STORED_DENY) {
decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_STORED_DENY);
return;
}
// 6. a registered delegating client prompts per origin itself: no runtime
// prompt and no stored decision needed — and nothing more than that.
if (out->delegating) {
decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING);
return;
}
// 7. a stored Allow.
if (stored == U_CAMERA_CONSENT_STORED_ALLOW) {
decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_STORED_ALLOW);
return;
}
// 7. "Allow once" already given to this process.
// 8. "Allow once" already given to this process.
if (once_find(c, exe, pid)) {
decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_ALLOW_ONCE);
return;
}
// 8. ask.
// 9. ask.
if (!c->prompt_enabled || c->env == NULL || c->env->prompt == NULL) {
decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE);
return;
Expand Down Expand Up @@ -225,6 +233,74 @@ u_camera_consent_evaluate(struct u_camera_consent *c,
}


/*
*
* Pure store helpers.
*
*/

static char
path_norm_ch(char c, bool windows_rules)
{
if (windows_rules) {
if (c == '/') {
return '\\';
}
if (c >= 'A' && c <= 'Z') {
return (char)(c - 'A' + 'a');
}
}
return c;
}

bool
u_camera_consent_path_equal(const char *a, const char *b, bool windows_rules)
{
if (a == NULL || b == NULL || a[0] == '\0' || b[0] == '\0') {
return false;
}
while (*a != '\0' && *b != '\0') {
if (path_norm_ch(*a, windows_rules) != path_norm_ch(*b, windows_rules)) {
return false;
}
a++;
b++;
}
return *a == '\0' && *b == '\0';
}

bool
u_camera_consent_list_find(
u_camera_consent_enum_fn fn, void *ctx, const char *exe, bool windows_rules, char *match_name, size_t match_cap)
{
if (fn == NULL || !exe_known(exe)) {
return false;
}
char name[1024];
char path[4096];
for (uint32_t i = 0; i < U_CAMERA_CONSENT_LIST_MAX; i++) {
name[0] = '\0';
path[0] = '\0';
enum u_camera_consent_enum_result r = fn(ctx, i, name, sizeof(name), path, sizeof(path));
if (r == U_CAMERA_CONSENT_ENUM_END) {
return false;
}
if (r != U_CAMERA_CONSENT_ENUM_ENTRY) {
continue; // one bad entry never hides the ones after it
}
name[sizeof(name) - 1] = '\0';
path[sizeof(path) - 1] = '\0';
if (u_camera_consent_path_equal(path, exe, windows_rules)) {
if (match_name != NULL && match_cap > 0) {
snprintf(match_name, match_cap, "%s", name);
}
return true;
}
}
return false;
}


/*
*
* Keyed persistentId.
Expand Down
Loading
Loading