release: WRNexusJS 0.8.0
This commit is contained in:
@@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti
|
||||
|
||||
Unavailable required plugins throw `MobileUnavailableError` with an actionable message.
|
||||
|
||||
The package also provides portable application-facing primitives:
|
||||
|
||||
- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list.
|
||||
- `PushNotifications` performs permission gating and validates registrations.
|
||||
- `SecureStorage` namespaces and validates keys over an application-supplied encrypted
|
||||
Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure.
|
||||
- `OfflineQueue` persists bounded sync batches through a pluggable durable store.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Capacitor plugin imports must remain in browser-owned modules.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -12,6 +12,84 @@ export function parseDeepLink(value: string, schemes: string[] = []): DeepLink |
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeepLinkSource {
|
||||
current?(): Promise<string | undefined>;
|
||||
subscribe(listener: (url: string) => void): void | (() => void);
|
||||
}
|
||||
|
||||
/** Normalize initial and live native links and ignore malformed/unapproved schemes. */
|
||||
export function listenDeepLinks(
|
||||
source: DeepLinkSource,
|
||||
listener: (link: DeepLink) => void,
|
||||
schemes: string[] = [],
|
||||
): () => void {
|
||||
let active = true;
|
||||
const emit = (value: string) => {
|
||||
const link = parseDeepLink(value, schemes);
|
||||
if (active && link) listener(link);
|
||||
};
|
||||
void source.current?.().then((value) => value && emit(value));
|
||||
const unsubscribe = source.subscribe(emit);
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushRegistration {
|
||||
token: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface PushAdapter {
|
||||
permission(): Promise<"granted" | "denied" | "prompt" | "unavailable">;
|
||||
requestPermission?(): Promise<"granted" | "denied">;
|
||||
register(): Promise<PushRegistration>;
|
||||
subscribe?(listener: (notification: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
export class PushNotifications {
|
||||
constructor(private readonly adapter: PushAdapter) {}
|
||||
async register(): Promise<PushRegistration> {
|
||||
let permission = await this.adapter.permission();
|
||||
if (permission === "prompt" && this.adapter.requestPermission)
|
||||
permission = await this.adapter.requestPermission();
|
||||
if (permission !== "granted") throw new Error("WRN-MOBILE-PUSH-PERMISSION-DENIED");
|
||||
const registration = await this.adapter.register();
|
||||
if (!registration.token.trim()) throw new Error("WRN-MOBILE-PUSH-EMPTY-TOKEN");
|
||||
return registration;
|
||||
}
|
||||
subscribe(listener: (notification: unknown) => void): () => void {
|
||||
return this.adapter.subscribe?.(listener) ?? (() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SecureStorageAdapter {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class SecureStorage {
|
||||
constructor(
|
||||
private readonly adapter: SecureStorageAdapter,
|
||||
private readonly namespace = "wrnexus",
|
||||
) {}
|
||||
#key(key: string): string {
|
||||
if (!/^[A-Za-z0-9._-]{1,128}$/.test(key)) throw new TypeError("WRN-MOBILE-STORAGE-KEY");
|
||||
return `${this.namespace}:${key}`;
|
||||
}
|
||||
get(key: string): Promise<string | null> {
|
||||
return this.adapter.get(this.#key(key));
|
||||
}
|
||||
set(key: string, value: string): Promise<void> {
|
||||
return this.adapter.set(this.#key(key), value);
|
||||
}
|
||||
remove(key: string): Promise<void> {
|
||||
return this.adapter.remove(this.#key(key));
|
||||
}
|
||||
}
|
||||
export interface OfflineTask<T = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -85,7 +163,11 @@ export interface MobileEnvironment {
|
||||
userAgent?: string;
|
||||
}
|
||||
export function mobileEnvironment(): MobileEnvironment {
|
||||
const capacitor = (globalThis as any).Capacitor;
|
||||
const capacitor = (
|
||||
globalThis as typeof globalThis & {
|
||||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||||
}
|
||||
).Capacitor;
|
||||
const native = capacitor?.isNativePlatform?.() === true;
|
||||
return {
|
||||
platform: capacitor?.getPlatform?.() ?? "web",
|
||||
|
||||
@@ -90,9 +90,21 @@ export const mobile = {
|
||||
whenNative,
|
||||
};
|
||||
export {
|
||||
listenDeepLinks,
|
||||
parseDeepLink,
|
||||
memoryOfflineTaskStore,
|
||||
OfflineQueue,
|
||||
PushNotifications,
|
||||
SecureStorage,
|
||||
mobileEnvironment,
|
||||
} from "./advanced.ts";
|
||||
export type { DeepLink, OfflineTask, OfflineTaskStore, MobileEnvironment } from "./advanced.ts";
|
||||
export type {
|
||||
DeepLink,
|
||||
DeepLinkSource,
|
||||
OfflineTask,
|
||||
OfflineTaskStore,
|
||||
MobileEnvironment,
|
||||
PushAdapter,
|
||||
PushRegistration,
|
||||
SecureStorageAdapter,
|
||||
} from "./advanced.ts";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import {
|
||||
MobileUnavailableError,
|
||||
PushNotifications,
|
||||
SecureStorage,
|
||||
invoke,
|
||||
isNative,
|
||||
platform,
|
||||
@@ -59,3 +61,24 @@ test("explains missing plugins", async () => {
|
||||
root.Capacitor = { isNativePlatform: () => true, Plugins: {} };
|
||||
expect(invoke("Camera", "getPhoto")).rejects.toBeInstanceOf(MobileUnavailableError);
|
||||
});
|
||||
|
||||
test("normalizes push permission and rejects empty registrations", async () => {
|
||||
const push = new PushNotifications({
|
||||
permission: async () => "prompt",
|
||||
requestPermission: async () => "granted",
|
||||
register: async () => ({ token: "device-token", platform: "ios" }),
|
||||
});
|
||||
expect(await push.register()).toEqual({ token: "device-token", platform: "ios" });
|
||||
});
|
||||
|
||||
test("namespaces and validates secure-storage keys", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = new SecureStorage({
|
||||
get: async (key) => values.get(key) ?? null,
|
||||
set: async (key, value) => void values.set(key, value),
|
||||
remove: async (key) => void values.delete(key),
|
||||
});
|
||||
await storage.set("session", "encrypted-value");
|
||||
expect(values.get("wrnexus:session")).toBe("encrypted-value");
|
||||
expect(() => storage.get("bad key")).toThrow(TypeError);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user