179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
export interface DeepLink {
|
|
url: URL;
|
|
path: string;
|
|
query: URLSearchParams;
|
|
}
|
|
export function parseDeepLink(value: string, schemes: string[] = []): DeepLink | null {
|
|
try {
|
|
const url = new URL(value);
|
|
if (schemes.length && !schemes.includes(url.protocol.replace(/:$/, ""))) return null;
|
|
return { url, path: url.pathname || "/", query: url.searchParams };
|
|
} catch {
|
|
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;
|
|
payload: T;
|
|
createdAt: number;
|
|
attempts: number;
|
|
}
|
|
export interface OfflineTaskStore {
|
|
load(): Promise<OfflineTask[]>;
|
|
save(tasks: OfflineTask[]): Promise<void>;
|
|
}
|
|
export function memoryOfflineTaskStore(): OfflineTaskStore {
|
|
let tasks: OfflineTask[] = [];
|
|
return {
|
|
async load() {
|
|
return structuredClone(tasks);
|
|
},
|
|
async save(next) {
|
|
tasks = structuredClone(next);
|
|
},
|
|
};
|
|
}
|
|
export class OfflineQueue {
|
|
readonly #handlers = new Map<string, (payload: unknown) => Promise<void>>();
|
|
constructor(private readonly store: OfflineTaskStore = memoryOfflineTaskStore()) {}
|
|
process<T>(type: string, handler: (payload: T) => Promise<void>): void {
|
|
this.#handlers.set(type, handler as (payload: unknown) => Promise<void>);
|
|
}
|
|
async add<T>(type: string, payload: T): Promise<OfflineTask<T>> {
|
|
const tasks = await this.store.load();
|
|
const task = { id: crypto.randomUUID(), type, payload, createdAt: Date.now(), attempts: 0 };
|
|
tasks.push(task);
|
|
await this.store.save(tasks);
|
|
return task;
|
|
}
|
|
async sync(limit = 20): Promise<{ completed: number; failed: number }> {
|
|
const tasks = await this.store.load();
|
|
const remaining: OfflineTask[] = [];
|
|
let completed = 0,
|
|
failed = 0;
|
|
for (const task of tasks) {
|
|
if (completed + failed >= limit) {
|
|
remaining.push(task);
|
|
continue;
|
|
}
|
|
const handler = this.#handlers.get(task.type);
|
|
if (!handler) {
|
|
remaining.push(task);
|
|
continue;
|
|
}
|
|
try {
|
|
task.attempts++;
|
|
await handler(task.payload);
|
|
completed++;
|
|
} catch {
|
|
failed++;
|
|
remaining.push(task);
|
|
}
|
|
}
|
|
await this.store.save(remaining);
|
|
return { completed, failed };
|
|
}
|
|
async size(): Promise<number> {
|
|
return (await this.store.load()).length;
|
|
}
|
|
}
|
|
export interface MobileEnvironment {
|
|
platform: string;
|
|
native: boolean;
|
|
online: boolean;
|
|
userAgent?: string;
|
|
}
|
|
export function mobileEnvironment(): MobileEnvironment {
|
|
const capacitor = (
|
|
globalThis as typeof globalThis & {
|
|
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
|
}
|
|
).Capacitor;
|
|
const native = capacitor?.isNativePlatform?.() === true;
|
|
return {
|
|
platform: capacitor?.getPlatform?.() ?? "web",
|
|
native,
|
|
online: typeof navigator === "undefined" ? true : navigator.onLine,
|
|
userAgent: typeof navigator === "undefined" ? undefined : navigator.userAgent,
|
|
};
|
|
}
|