@symbiote-native/brightness 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/build/angular/index.d.ts +1 -1
  2. package/build/angular/index.js +1 -1
  3. package/build/angular/services/permissions.service/index.d.ts +4 -0
  4. package/build/angular/services/permissions.service/index.js +20 -3
  5. package/build/core/brightness.d.ts +5 -2
  6. package/build/core/brightness.js +5 -2
  7. package/build/react/hooks/use-permissions/index.d.ts +2 -1
  8. package/build/react/hooks/use-permissions/index.js +18 -5
  9. package/build/react/index.d.ts +1 -1
  10. package/build/react/index.js +1 -1
  11. package/build/svelte/index.d.ts +2 -0
  12. package/build/svelte/index.js +6 -0
  13. package/build/svelte/runes/use-permissions.svelte.d.ts +7 -0
  14. package/build/svelte/runes/use-permissions.svelte.js +55 -0
  15. package/build/vue/composables/use-permissions/index.d.ts +1 -0
  16. package/build/vue/composables/use-permissions/index.js +13 -3
  17. package/build/vue/index.d.ts +1 -1
  18. package/build/vue/index.js +1 -1
  19. package/build-ngc/angular/index.d.ts +1 -1
  20. package/build-ngc/angular/index.js +1 -1
  21. package/build-ngc/angular/index.js.map +1 -1
  22. package/build-ngc/angular/services/permissions.service/index.d.ts +4 -0
  23. package/build-ngc/angular/services/permissions.service/index.js +23 -5
  24. package/build-ngc/angular/services/permissions.service/index.js.map +1 -1
  25. package/build-ngc/core/brightness.d.ts +5 -2
  26. package/build-ngc/core/brightness.js +5 -2
  27. package/build-ngc/core/brightness.js.map +1 -1
  28. package/package.json +30 -13
  29. package/src/angular/index.ts +1 -20
  30. package/src/angular/services/permissions.service/index.ts +20 -2
  31. package/src/core/brightness.ts +5 -2
  32. package/src/react/hooks/use-permissions/index.ts +19 -4
  33. package/src/react/index.ts +1 -20
  34. package/src/svelte/index.ts +7 -0
  35. package/src/svelte/runes/use-permissions.svelte.ts +65 -0
  36. package/src/svelte/svelte-ambient.d.ts +6 -0
  37. package/src/vue/composables/use-permissions/index.ts +13 -2
  38. package/src/vue/index.ts +1 -20
  39. package/src/angular/services/permissions.service/permissions.service.test.ts +0 -107
  40. package/src/core/brightness.test.ts +0 -334
  41. package/src/react/hooks/use-permissions/use-permissions.test.tsx +0 -100
  42. package/src/vue/composables/use-permissions/use-permissions.test.ts +0 -99
@@ -5,6 +5,11 @@
5
5
  //
6
6
  // readonly status = inject(PermissionsService).connect();
7
7
  // // template: {{ status()?.granted }}
8
+ //
9
+ // The auto-fetch has nobody to reject to, so its failure lands in the `error` signal instead of
10
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
11
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
12
+ // only the automatic call is made safe.
8
13
  import { Injectable, signal, type Signal } from '@angular/core';
9
14
  import {
10
15
  getPermissionsAsync,
@@ -15,10 +20,21 @@ import {
15
20
  @Injectable({ providedIn: 'root' })
16
21
  export class PermissionsService {
17
22
  private readonly status = signal<PermissionResponse | null>(null);
23
+ private readonly fetchError = signal<Error | null>(null);
24
+ private isAutoFetchStarted = false;
25
+
26
+ /** Why the auto-fetch left `status` at null. Cleared by the next successful get()/request(). */
27
+ readonly error: Signal<Error | null> = this.fetchError.asReadonly();
18
28
 
19
29
  connect(): Signal<PermissionResponse | null> {
20
- if (this.status() === null) {
21
- void this.get();
30
+ // Latched rather than guarded on `status() === null`: a failed fetch leaves status null
31
+ // forever, so the old guard turned every later connect() into another native call — unbounded
32
+ // once connect() is reached from a change-detected expression instead of a field initializer.
33
+ if (!this.isAutoFetchStarted) {
34
+ this.isAutoFetchStarted = true;
35
+ this.get().catch((cause: unknown) => {
36
+ this.fetchError.set(cause instanceof Error ? cause : new Error(String(cause)));
37
+ });
22
38
  }
23
39
  return this.status.asReadonly();
24
40
  }
@@ -26,12 +42,14 @@ export class PermissionsService {
26
42
  async get(): Promise<PermissionResponse> {
27
43
  const response = await getPermissionsAsync();
28
44
  this.status.set(response);
45
+ this.fetchError.set(null);
29
46
  return response;
30
47
  }
31
48
 
32
49
  async request(): Promise<PermissionResponse> {
33
50
  const response = await requestPermissionsAsync();
34
51
  this.status.set(response);
52
+ this.fetchError.set(null);
35
53
  return response;
36
54
  }
37
55
  }
@@ -90,8 +90,11 @@ export async function restoreSystemBrightnessAsync(): Promise<void> {
90
90
  }
91
91
 
92
92
  /**
93
- * Whether the app is currently controlling the system brightness (as opposed to only its own
94
- * window's brightness). Always `false` on every platform except Android.
93
+ * Whether the activity's window has NO brightness override of its own, so the system-wide
94
+ * brightness is what the screen shows. Native reads `screenBrightness == BRIGHTNESS_OVERRIDE_NONE`,
95
+ * which cannot tell "the app set the system value" apart from "the app never touched brightness" —
96
+ * it reports only that `setBrightnessAsync` is not currently overriding this window.
97
+ * Always `false` on every platform except Android.
95
98
  * @platform android
96
99
  */
97
100
  export async function isUsingSystemBrightnessAsync(): Promise<boolean> {
@@ -2,6 +2,11 @@
2
2
  // the current permission status on mount, then exposes get/request as imperative callbacks.
3
3
  // Mirrors the shape @symbiote-native/cellular's own usePermissions ships in parallel, so both
4
4
  // stay byte-for-byte consistent.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the 4th tuple slot (`error`)
7
+ // instead of escaping as an unhandled rejection: a null status with a non-null error is how a
8
+ // consumer tells "the fetch failed" from "not fetched yet". get()/request() still reject to their
9
+ // direct caller — only the automatic call is made safe.
5
10
  import { useCallback, useEffect, useRef, useState } from 'react';
6
11
  import {
7
12
  getPermissionsAsync,
@@ -13,29 +18,39 @@ export function usePermissions(): [
13
18
  PermissionResponse | null,
14
19
  () => Promise<PermissionResponse>,
15
20
  () => Promise<PermissionResponse>,
21
+ Error | null,
16
22
  ] {
17
23
  const isMounted = useRef(true);
18
24
  const [status, setStatus] = useState<PermissionResponse | null>(null);
25
+ const [error, setError] = useState<Error | null>(null);
19
26
 
20
27
  const getPermission = useCallback(async () => {
21
28
  const response = await getPermissionsAsync();
22
- if (isMounted.current) setStatus(response);
29
+ if (isMounted.current) {
30
+ setStatus(response);
31
+ setError(null);
32
+ }
23
33
  return response;
24
34
  }, []);
25
35
 
26
36
  const requestPermission = useCallback(async () => {
27
37
  const response = await requestPermissionsAsync();
28
- if (isMounted.current) setStatus(response);
38
+ if (isMounted.current) {
39
+ setStatus(response);
40
+ setError(null);
41
+ }
29
42
  return response;
30
43
  }, []);
31
44
 
32
45
  useEffect(() => {
33
46
  isMounted.current = true;
34
- void getPermission();
47
+ getPermission().catch((cause: unknown) => {
48
+ if (isMounted.current) setError(cause instanceof Error ? cause : new Error(String(cause)));
49
+ });
35
50
  return () => {
36
51
  isMounted.current = false;
37
52
  };
38
53
  }, [getPermission]);
39
54
 
40
- return [status, requestPermission, getPermission];
55
+ return [status, requestPermission, getPermission, error];
41
56
  }
@@ -4,23 +4,4 @@
4
4
  // mirrors the lifecycle-bucket naming convention of adapters/react/src/hooks.
5
5
 
6
6
  export { usePermissions } from './hooks/use-permissions';
7
- export {
8
- isAvailableAsync,
9
- getBrightnessAsync,
10
- setBrightnessAsync,
11
- getSystemBrightnessAsync,
12
- setSystemBrightnessAsync,
13
- restoreSystemBrightnessAsync,
14
- isUsingSystemBrightnessAsync,
15
- getSystemBrightnessModeAsync,
16
- setSystemBrightnessModeAsync,
17
- getPermissionsAsync,
18
- requestPermissionsAsync,
19
- addBrightnessListener,
20
- BrightnessMode,
21
- PermissionStatus,
22
- type BrightnessEvent,
23
- type PermissionExpiration,
24
- type PermissionResponse,
25
- type EventSubscription,
26
- } from '../core';
7
+ export * from '../core';
@@ -0,0 +1,7 @@
1
+ // @symbiote-native/brightness/svelte: the Svelte entry over the framework-agnostic core. Same
2
+ // reasoning as the React and Vue entries — only the permission surface gets a lifecycle rune,
3
+ // every other export is a plain re-export — mirrors the lifecycle-bucket naming convention of
4
+ // adapters/svelte/src/runes (never `hooks/`/`composables/`, those are React's and Vue's terms).
5
+
6
+ export { usePermissions } from './runes/use-permissions.svelte';
7
+ export * from '../core';
@@ -0,0 +1,65 @@
1
+ // Svelte lifecycle wiring over the framework-agnostic core (core/brightness.ts) — auto-fetches
2
+ // the current permission status on mount, then exposes get/request as imperative callbacks. The
3
+ // Svelte twin of vue/composables/use-permissions and react/hooks/use-permissions.
4
+ //
5
+ // `.svelte.ts` (not `.ts`): runes ($state/$effect) are only usable in a file with this extension
6
+ // outside an actual `.svelte` component. `runes/` is Svelte's own term for the bucket React calls
7
+ // `hooks/` and Vue calls `composables/` — see adapters/svelte/src/runes.
8
+ //
9
+ // Vue's onMounted collapses into a `$effect` with no teardown (there is no ongoing subscription
10
+ // here, just a one-shot get). The effect only WRITES `status` (through `get()`), never reads it,
11
+ // so its dependency set stays empty and it fires exactly once on mount.
12
+ //
13
+ // `status` comes back as a GETTER on the returned object rather than a raw `$state` variable:
14
+ // Svelte 5 reactivity is lexically scoped to the declaring module, so a raw `let x = $state(...)`
15
+ // handed out of a plain function arrives dead at the caller. Reading `.status` is the Svelte
16
+ // equivalent of unwrapping Vue's `Ref` via `.value`.
17
+ //
18
+ // The mount fetch has nobody to reject to, so its failure lands in `error` (a second boxed getter,
19
+ // same reason as `status`) instead of escaping as an unhandled rejection: a null status with a
20
+ // non-null error is how a consumer tells "the fetch failed" from "not fetched yet".
21
+ // get()/request() still reject to their direct caller — only the automatic call is made safe.
22
+ import { getPermissionsAsync, requestPermissionsAsync, type PermissionResponse } from '../../core';
23
+
24
+ export function usePermissions(): {
25
+ readonly status: PermissionResponse | null;
26
+ readonly error: Error | null;
27
+ request: () => Promise<PermissionResponse>;
28
+ get: () => Promise<PermissionResponse>;
29
+ } {
30
+ let status = $state<PermissionResponse | null>(null);
31
+ let error = $state<Error | null>(null);
32
+
33
+ const get = async (): Promise<PermissionResponse> => {
34
+ const response = await getPermissionsAsync();
35
+ status = response;
36
+ error = null;
37
+ return response;
38
+ };
39
+
40
+ const request = async (): Promise<PermissionResponse> => {
41
+ const response = await requestPermissionsAsync();
42
+ status = response;
43
+ error = null;
44
+ return response;
45
+ };
46
+
47
+ $effect(() => {
48
+ // The catch runs after the effect has settled, so writing `error` there adds no dependency and
49
+ // the effect still fires exactly once.
50
+ get().catch((cause: unknown) => {
51
+ error = cause instanceof Error ? cause : new Error(String(cause));
52
+ });
53
+ });
54
+
55
+ return {
56
+ get status(): PermissionResponse | null {
57
+ return status;
58
+ },
59
+ get error(): Error | null {
60
+ return error;
61
+ },
62
+ request,
63
+ get,
64
+ };
65
+ }
@@ -0,0 +1,6 @@
1
+ // Pulls in svelte's ambient global declarations ($state/$effect/...) for this package's tsc
2
+ // program. adapters/svelte gets these for free because render.ts does a real value import from
3
+ // 'svelte' (any file in a program importing a module loads its ambient globals for the whole
4
+ // program); this package has no such import, so a triple-slash reference does the same job
5
+ // explicitly rather than relying on an incidental side-effect.
6
+ /// <reference types="svelte" />
@@ -2,6 +2,11 @@
2
2
  // current permission status on mount (mirrors use-battery-level's onMounted/onUnmounted style,
3
3
  // but there is no ongoing subscription to tear down here, just a one-shot get), then exposes
4
4
  // get/request as imperative callbacks.
5
+ //
6
+ // The mount fetch has nobody to reject to, so its failure lands in the `error` ref instead of
7
+ // escaping as an unhandled rejection: a null status with a non-null error is how a consumer tells
8
+ // "the fetch failed" from "not fetched yet". get()/request() still reject to their direct caller —
9
+ // only the automatic call is made safe.
5
10
  import { onMounted, ref, type Ref } from '@vue/runtime-core';
6
11
  import {
7
12
  getPermissionsAsync,
@@ -11,26 +16,32 @@ import {
11
16
 
12
17
  export function usePermissions(): {
13
18
  status: Ref<PermissionResponse | null>;
19
+ error: Ref<Error | null>;
14
20
  request: () => Promise<PermissionResponse>;
15
21
  get: () => Promise<PermissionResponse>;
16
22
  } {
17
23
  const status = ref<PermissionResponse | null>(null);
24
+ const error = ref<Error | null>(null);
18
25
 
19
26
  const get = async () => {
20
27
  const response = await getPermissionsAsync();
21
28
  status.value = response;
29
+ error.value = null;
22
30
  return response;
23
31
  };
24
32
 
25
33
  const request = async () => {
26
34
  const response = await requestPermissionsAsync();
27
35
  status.value = response;
36
+ error.value = null;
28
37
  return response;
29
38
  };
30
39
 
31
40
  onMounted(() => {
32
- void get();
41
+ get().catch((cause: unknown) => {
42
+ error.value = cause instanceof Error ? cause : new Error(String(cause));
43
+ });
33
44
  });
34
45
 
35
- return { status, request, get };
46
+ return { status, error, request, get };
36
47
  }
package/src/vue/index.ts CHANGED
@@ -4,23 +4,4 @@
4
4
  // adapters/vue/src/composables (never `hooks/`, that's React's term).
5
5
 
6
6
  export { usePermissions } from './composables/use-permissions';
7
- export {
8
- isAvailableAsync,
9
- getBrightnessAsync,
10
- setBrightnessAsync,
11
- getSystemBrightnessAsync,
12
- setSystemBrightnessAsync,
13
- restoreSystemBrightnessAsync,
14
- isUsingSystemBrightnessAsync,
15
- getSystemBrightnessModeAsync,
16
- setSystemBrightnessModeAsync,
17
- getPermissionsAsync,
18
- requestPermissionsAsync,
19
- addBrightnessListener,
20
- BrightnessMode,
21
- PermissionStatus,
22
- type BrightnessEvent,
23
- type PermissionExpiration,
24
- type PermissionResponse,
25
- type EventSubscription,
26
- } from '../core';
7
+ export * from '../core';
@@ -1,107 +0,0 @@
1
- // Co-located Angular-driven test (ADR 0025) for PermissionsService. See battery's
2
- // battery-state.service.test.ts for the shared rationale.
3
-
4
- import '@angular/compiler';
5
- import { Component, inject, type Signal } from '@angular/core';
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
- import { mount, unmount } from '@symbiote-native/angular';
8
- import { installFabric } from '@symbiote-native/test-utils';
9
- import { PermissionsService } from './index';
10
- import { PermissionStatus, type PermissionResponse } from '../../../core';
11
-
12
- const { getPermissionsAsync, requestPermissionsAsync } = vi.hoisted(() => ({
13
- getPermissionsAsync: vi.fn(),
14
- requestPermissionsAsync: vi.fn(),
15
- }));
16
-
17
- // Same enum-shaped-object mock trick packages/battery/src/angular/services/battery-state.service's
18
- // test uses for BatteryState.
19
- vi.mock('../../../core', () => ({
20
- getPermissionsAsync,
21
- requestPermissionsAsync,
22
- PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' },
23
- }));
24
-
25
- const GRANTED: PermissionResponse = {
26
- status: PermissionStatus.GRANTED,
27
- granted: true,
28
- canAskAgain: true,
29
- expires: 'never',
30
- };
31
- const DENIED: PermissionResponse = {
32
- status: PermissionStatus.DENIED,
33
- granted: false,
34
- canAskAgain: true,
35
- expires: 'never',
36
- };
37
-
38
- const ROOT_TAG = 973;
39
- const fabric = installFabric();
40
- const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
41
-
42
- let capturedStatus: Signal<PermissionResponse | null> | undefined;
43
- let capturedService: PermissionsService | undefined;
44
-
45
- @Component({
46
- selector: 'symbiote-permissions-host',
47
- standalone: true,
48
- template: '',
49
- })
50
- class PermissionsHost {
51
- readonly service = inject(PermissionsService);
52
- readonly status = this.service.connect();
53
-
54
- constructor() {
55
- capturedStatus = this.status;
56
- capturedService = this.service;
57
- }
58
- }
59
-
60
- beforeEach(() => {
61
- capturedStatus = undefined;
62
- capturedService = undefined;
63
- vi.clearAllMocks();
64
- getPermissionsAsync.mockResolvedValue(GRANTED);
65
- requestPermissionsAsync.mockResolvedValue(GRANTED);
66
- });
67
-
68
- afterEach(() => {
69
- unmount(ROOT_TAG);
70
- fabric.reset();
71
- });
72
-
73
- describe('PermissionsService.connect', () => {
74
- it('reports null before the initial fetch resolves', () => {
75
- mount(ROOT_TAG, PermissionsHost);
76
-
77
- expect(capturedStatus?.()).toBe(null);
78
- });
79
-
80
- it('reports the fetched status once getPermissionsAsync() resolves', async () => {
81
- mount(ROOT_TAG, PermissionsHost);
82
- await tick();
83
-
84
- expect(capturedStatus?.()).toEqual(GRANTED);
85
- expect(getPermissionsAsync).toHaveBeenCalledTimes(1);
86
- });
87
-
88
- it('request() delegates to requestPermissionsAsync and updates the signal', async () => {
89
- mount(ROOT_TAG, PermissionsHost);
90
- await tick();
91
-
92
- requestPermissionsAsync.mockResolvedValueOnce(DENIED);
93
- await capturedService?.request();
94
-
95
- expect(capturedStatus?.()).toEqual(DENIED);
96
- });
97
-
98
- it('get() re-fetches and updates the signal', async () => {
99
- mount(ROOT_TAG, PermissionsHost);
100
- await tick();
101
-
102
- getPermissionsAsync.mockResolvedValueOnce(DENIED);
103
- await capturedService?.get();
104
-
105
- expect(capturedStatus?.()).toEqual(DENIED);
106
- });
107
- });