@parity/product-sdk-host 0.3.0 → 0.5.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Higher-level wrappers for the host's single-permission flows.
3
+ *
4
+ * `hostApi.permission` / `hostApi.devicePermission` take a versioned
5
+ * envelope (`enumValue("v1", ...)`) and return a neverthrow `ResultAsync`
6
+ * of an unwrapped versioned response. Consumers rebuild that wrap/unwrap
7
+ * dance every time. {@link requestPermission} and
8
+ * {@link requestDevicePermission} collapse it to one-liners that match the
9
+ * shape of {@link requestResourceAllocation} (throws on error, returns
10
+ * the unwrapped payload on success).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { createLogger } from "@parity/product-sdk-logger";
16
+
17
+ import type { CodecType } from "@novasamatech/host-api";
18
+ import type { DevicePermission as DevicePermissionCodec } from "@novasamatech/host-api";
19
+
20
+ import { enumValue, formatHostError, getTruApi, type RemotePermission } from "./truapi.js";
21
+
22
+ const log = createLogger("host:permissions");
23
+
24
+ /**
25
+ * Device permission the dapp can ask the host to grant via
26
+ * {@link requestDevicePermission}.
27
+ *
28
+ * Derived from the upstream codec so variant renames surface as compile
29
+ * errors, not runtime failures.
30
+ */
31
+ export type DevicePermissionKind = CodecType<typeof DevicePermissionCodec>;
32
+
33
+ /**
34
+ * Alias of {@link RemotePermission} matching the upstream
35
+ * `@novasamatech/host-api-wrapper` name. Use either freely.
36
+ */
37
+ export type RemotePermissionItem = RemotePermission;
38
+
39
+ /**
40
+ * Request a single remote permission from the host.
41
+ *
42
+ * Builds the `v1` envelope, calls `hostApi.permission`, unwraps the response,
43
+ * and returns the host's boolean granted/denied outcome.
44
+ *
45
+ * @param permission - The remote permission to request.
46
+ * @returns `true` if the host granted the permission, `false` if denied.
47
+ * @throws If the host is unavailable or the request fails.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const granted = await requestPermission({ tag: "ChainSubmit", value: undefined });
52
+ * if (!granted) {
53
+ * tellUserToReconnect();
54
+ * }
55
+ * ```
56
+ */
57
+ export async function requestPermission(permission: RemotePermission): Promise<boolean> {
58
+ const truApi = await getTruApi();
59
+ if (!truApi) {
60
+ throw new Error("requestPermission: TruAPI unavailable");
61
+ }
62
+ log.debug("requestPermission", { tag: permission.tag });
63
+
64
+ return await truApi.permission(enumValue("v1", permission)).match(
65
+ (envelope: { tag: "v1"; value: boolean }) => envelope.value,
66
+ (err: unknown) => {
67
+ throw new Error(`requestPermission failed: ${formatHostError(err)}`, { cause: err });
68
+ },
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Request a single device permission (camera, microphone, etc.) from the
74
+ * host.
75
+ *
76
+ * Builds the `v1` envelope, calls `hostApi.devicePermission`, unwraps the
77
+ * response, and returns the host's boolean granted/denied outcome.
78
+ *
79
+ * @param permission - The device permission to request.
80
+ * @returns `true` if the host granted the permission, `false` if denied.
81
+ * @throws If the host is unavailable or the request fails.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const granted = await requestDevicePermission("Camera");
86
+ * if (!granted) {
87
+ * showCameraDeniedMessage();
88
+ * }
89
+ * ```
90
+ */
91
+ export async function requestDevicePermission(permission: DevicePermissionKind): Promise<boolean> {
92
+ const truApi = await getTruApi();
93
+ if (!truApi) {
94
+ throw new Error("requestDevicePermission: TruAPI unavailable");
95
+ }
96
+ log.debug("requestDevicePermission", { permission });
97
+
98
+ return await truApi.devicePermission(enumValue("v1", permission)).match(
99
+ (envelope: { tag: "v1"; value: boolean }) => envelope.value,
100
+ (err: unknown) => {
101
+ throw new Error(`requestDevicePermission failed: ${formatHostError(err)}`, {
102
+ cause: err,
103
+ });
104
+ },
105
+ );
106
+ }
107
+
108
+ if (import.meta.vitest) {
109
+ const { test, expect, describe, vi } = import.meta.vitest;
110
+
111
+ async function withMockedTruApi<T>(
112
+ bridge: {
113
+ permission?: (req: unknown) => unknown;
114
+ devicePermission?: (req: unknown) => unknown;
115
+ } | null,
116
+ fn: (mod: typeof import("./permissions.js")) => Promise<T>,
117
+ ): Promise<T> {
118
+ vi.resetModules();
119
+ vi.doMock("./truapi.js", async (importOriginal) => {
120
+ const original = await importOriginal<typeof import("./truapi.js")>();
121
+ return {
122
+ ...original,
123
+ getTruApi: async () => bridge,
124
+ enumValue: (version: string, value: unknown) => ({ tag: version, value }),
125
+ };
126
+ });
127
+ try {
128
+ const mod = await import("./permissions.js");
129
+ return await fn(mod);
130
+ } finally {
131
+ vi.doUnmock("./truapi.js");
132
+ vi.resetModules();
133
+ }
134
+ }
135
+
136
+ describe("requestPermission", () => {
137
+ test("throws when TruAPI is unavailable", async () => {
138
+ await withMockedTruApi(null, async (mod) => {
139
+ await expect(
140
+ mod.requestPermission({ tag: "ChainSubmit", value: undefined }),
141
+ ).rejects.toThrow(/TruAPI unavailable/);
142
+ });
143
+ });
144
+
145
+ test("unwraps the v1 boolean outcome", async () => {
146
+ await withMockedTruApi(
147
+ {
148
+ permission: vi.fn().mockReturnValue({
149
+ match: async (onOk: (v: unknown) => unknown) =>
150
+ onOk({ tag: "v1", value: true }),
151
+ }),
152
+ },
153
+ async (mod) => {
154
+ const granted = await mod.requestPermission({
155
+ tag: "ChainSubmit",
156
+ value: undefined,
157
+ });
158
+ expect(granted).toBe(true);
159
+ },
160
+ );
161
+ });
162
+
163
+ test("wraps host errors with a diagnostic message", async () => {
164
+ await withMockedTruApi(
165
+ {
166
+ permission: vi.fn().mockReturnValue({
167
+ match: async (
168
+ _onOk: (v: unknown) => unknown,
169
+ onErr: (e: unknown) => unknown,
170
+ ) =>
171
+ onErr({
172
+ tag: "v1",
173
+ value: { name: "GenericError", message: "boom" },
174
+ }),
175
+ }),
176
+ },
177
+ async (mod) => {
178
+ await expect(
179
+ mod.requestPermission({ tag: "ChainSubmit", value: undefined }),
180
+ ).rejects.toThrow(/requestPermission failed: GenericError: boom/);
181
+ },
182
+ );
183
+ });
184
+ });
185
+
186
+ describe("requestDevicePermission", () => {
187
+ test("throws when TruAPI is unavailable", async () => {
188
+ await withMockedTruApi(null, async (mod) => {
189
+ await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
190
+ /TruAPI unavailable/,
191
+ );
192
+ });
193
+ });
194
+
195
+ test("unwraps the v1 boolean outcome", async () => {
196
+ await withMockedTruApi(
197
+ {
198
+ devicePermission: vi.fn().mockReturnValue({
199
+ match: async (onOk: (v: unknown) => unknown) =>
200
+ onOk({ tag: "v1", value: true }),
201
+ }),
202
+ },
203
+ async (mod) => {
204
+ const granted = await mod.requestDevicePermission("Camera");
205
+ expect(granted).toBe(true);
206
+ },
207
+ );
208
+ });
209
+
210
+ test("wraps host errors with a diagnostic message", async () => {
211
+ await withMockedTruApi(
212
+ {
213
+ devicePermission: vi.fn().mockReturnValue({
214
+ match: async (
215
+ _onOk: (v: unknown) => unknown,
216
+ onErr: (e: unknown) => unknown,
217
+ ) =>
218
+ onErr({
219
+ tag: "v1",
220
+ value: { name: "GenericError", message: "boom" },
221
+ }),
222
+ }),
223
+ },
224
+ async (mod) => {
225
+ await expect(mod.requestDevicePermission("Camera")).rejects.toThrow(
226
+ /requestDevicePermission failed: GenericError: boom/,
227
+ );
228
+ },
229
+ );
230
+ });
231
+ });
232
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Higher-level wrapper for the host's theme subscription.
3
+ *
4
+ * `hostApi.themeSubscribe` is reachable via {@link getTruApi}, but consumers
5
+ * have to wire the subscription envelope themselves. `getThemeProvider`
6
+ * returns the `@novasamatech/host-api-wrapper` theme provider object directly,
7
+ * giving callers a `subscribeTheme(cb)` method that resolves to a typed
8
+ * `ThemeMode` ("Light" | "Dark") and yields a `Subscription<void>` handle.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { createLogger } from "@parity/product-sdk-logger";
14
+
15
+ import type {
16
+ createThemeProvider,
17
+ ThemeMode as NovasamaThemeMode,
18
+ } from "@novasamatech/host-api-wrapper";
19
+
20
+ const log = createLogger("host:theme");
21
+
22
+ /**
23
+ * Host theme provider handle. Exposes `subscribeTheme(callback)` which
24
+ * receives a typed `ThemeMode` on every change and returns a
25
+ * `Subscription<void>` (`unsubscribe` + `onInterrupt`).
26
+ *
27
+ * Type identical to `createThemeProvider()` from
28
+ * `@novasamatech/host-api-wrapper`.
29
+ */
30
+ export type ThemeProvider = ReturnType<typeof createThemeProvider>;
31
+
32
+ /** Host theme mode value. Re-exported from `@novasamatech/host-api-wrapper`. */
33
+ export type ThemeMode = NovasamaThemeMode;
34
+
35
+ /**
36
+ * Get the host theme provider.
37
+ *
38
+ * Returns the theme-subscription handle exported by
39
+ * `@novasamatech/host-api-wrapper`, or `null` if the package is unavailable
40
+ * (running outside a host container or the optional peer dep isn't
41
+ * installed).
42
+ *
43
+ * Implementation note: upstream `@novasamatech/host-api-wrapper` exports only
44
+ * the `createThemeProvider` factory and no `themeProvider` singleton, so
45
+ * this getter constructs a fresh instance on each call (unlike
46
+ * {@link getPreimageManager} or {@link getHostLocalStorage}, which return
47
+ * upstream singletons). The constructed provider is cheap to allocate; it
48
+ * only opens a subscription when `subscribeTheme` is called.
49
+ *
50
+ * @returns The theme provider, or `null` if unavailable.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { getThemeProvider } from "@parity/product-sdk-host";
55
+ *
56
+ * const provider = await getThemeProvider();
57
+ * if (provider) {
58
+ * const sub = provider.subscribeTheme((mode) => {
59
+ * document.documentElement.dataset.theme = mode.toLowerCase();
60
+ * });
61
+ * // sub.unsubscribe() to stop listening
62
+ * }
63
+ * ```
64
+ */
65
+ export async function getThemeProvider(): Promise<ThemeProvider | null> {
66
+ try {
67
+ const sdk = await import("@novasamatech/host-api-wrapper");
68
+ return sdk.createThemeProvider();
69
+ } catch (err) {
70
+ log.debug("getThemeProvider unavailable", err);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ if (import.meta.vitest) {
76
+ const { test, expect } = import.meta.vitest;
77
+
78
+ test("getThemeProvider returns provider when SDK is available", async () => {
79
+ const provider = await getThemeProvider();
80
+ expect(provider === null || typeof provider === "object").toBe(true);
81
+ });
82
+ }