first commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import type { NativeCapability } from "./types.ts";
|
||||
|
||||
type ShareOptions = { title?: string; text?: string; url?: string };
|
||||
type PositionOptions = globalThis.PositionOptions;
|
||||
|
||||
export const browserCapabilities: Record<string, NativeCapability> = {
|
||||
"clipboard.write": {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined" && !!navigator.clipboard?.writeText,
|
||||
run: (options) =>
|
||||
navigator.clipboard.writeText(String((options as { text?: unknown })?.text ?? "")),
|
||||
},
|
||||
},
|
||||
share: {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined" && typeof navigator.share === "function",
|
||||
run: (options) => navigator.share(options as ShareOptions),
|
||||
},
|
||||
},
|
||||
geolocation: {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined" && !!navigator.geolocation,
|
||||
run: (options) =>
|
||||
new Promise((resolve, reject) =>
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options as PositionOptions),
|
||||
),
|
||||
},
|
||||
},
|
||||
network: {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined",
|
||||
run: () => ({ connected: navigator.onLine }),
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia,
|
||||
run: (options) =>
|
||||
navigator.mediaDevices.getUserMedia({ video: options ?? true, audio: false }),
|
||||
},
|
||||
},
|
||||
"storage.get": {
|
||||
browser: {
|
||||
supported: () => typeof localStorage !== "undefined",
|
||||
run: (options) => ({
|
||||
value: localStorage.getItem(String((options as { key?: unknown })?.key ?? "")),
|
||||
}),
|
||||
},
|
||||
},
|
||||
"storage.set": {
|
||||
browser: {
|
||||
supported: () => typeof localStorage !== "undefined",
|
||||
run: (options) => {
|
||||
const value = options as { key?: unknown; value?: unknown };
|
||||
localStorage.setItem(String(value?.key ?? ""), String(value?.value ?? ""));
|
||||
},
|
||||
},
|
||||
},
|
||||
"notifications.schedule": {
|
||||
browser: {
|
||||
supported: () => typeof Notification !== "undefined",
|
||||
run: async (options) => {
|
||||
if (Notification.permission === "default") await Notification.requestPermission();
|
||||
if (Notification.permission !== "granted")
|
||||
throw new Error("Notification permission was not granted");
|
||||
const value = options as { title?: string; body?: string };
|
||||
return new Notification(value?.title ?? "Notification", { body: value?.body });
|
||||
},
|
||||
},
|
||||
},
|
||||
"device.info": {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined",
|
||||
run: () => ({
|
||||
platform: "web",
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
}),
|
||||
},
|
||||
},
|
||||
haptics: {
|
||||
browser: {
|
||||
supported: () => typeof navigator !== "undefined" && typeof navigator.vibrate === "function",
|
||||
run: (options) => navigator.vibrate((options as { duration?: number })?.duration ?? 20),
|
||||
},
|
||||
},
|
||||
"filesystem.read": {
|
||||
browser: {
|
||||
supported: () => typeof window !== "undefined" && "showOpenFilePicker" in window,
|
||||
run: async () => {
|
||||
const picker = (
|
||||
window as unknown as Window & {
|
||||
showOpenFilePicker(): Promise<Array<{ getFile(): Promise<File> }>>;
|
||||
}
|
||||
).showOpenFilePicker;
|
||||
const [handle] = await picker.call(window);
|
||||
return handle?.getFile();
|
||||
},
|
||||
},
|
||||
},
|
||||
"filesystem.write": {
|
||||
browser: {
|
||||
supported: () => typeof window !== "undefined" && "showSaveFilePicker" in window,
|
||||
run: async (options) => {
|
||||
const picker = (
|
||||
window as unknown as Window & {
|
||||
showSaveFilePicker(): Promise<{
|
||||
createWritable(): Promise<{
|
||||
write(value: unknown): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
).showSaveFilePicker;
|
||||
const writable = await (await picker.call(window)).createWritable();
|
||||
await writable.write((options as { data?: unknown })?.data ?? "");
|
||||
await writable.close();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { browserCapabilities } from "./browser.ts";
|
||||
import { mobileCapabilities } from "./mobile.ts";
|
||||
import { isMobile, platform, register, registered, run, supports } from "./registry.ts";
|
||||
|
||||
const names = new Set([...Object.keys(browserCapabilities), ...Object.keys(mobileCapabilities)]);
|
||||
for (const name of names) {
|
||||
register(name, { ...browserCapabilities[name], ...mobileCapabilities[name] });
|
||||
}
|
||||
|
||||
export * from "./types.ts";
|
||||
export {
|
||||
NativeUnavailableError,
|
||||
clearRegistry,
|
||||
isMobile,
|
||||
platform,
|
||||
register,
|
||||
registered,
|
||||
run,
|
||||
supports,
|
||||
} from "./registry.ts";
|
||||
export { browserCapabilities } from "./browser.ts";
|
||||
export { mobileCapabilities } from "./mobile.ts";
|
||||
|
||||
export const native = { isMobile, platform, register, registered, run, supports };
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { NativeCapability } from "./types.ts";
|
||||
|
||||
type Plugin = Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
function plugin(name: string): Plugin | undefined {
|
||||
const root = globalThis as typeof globalThis & {
|
||||
Capacitor?: { Plugins?: Record<string, Plugin> };
|
||||
};
|
||||
return root.Capacitor?.Plugins?.[name];
|
||||
}
|
||||
|
||||
function capacitorAdapter(pluginName: string, method: string): NativeCapability["mobile"] {
|
||||
return {
|
||||
supported: () => typeof plugin(pluginName)?.[method] === "function",
|
||||
run: (options) => plugin(pluginName)![method]!(options),
|
||||
};
|
||||
}
|
||||
|
||||
function transformedCapacitorAdapter(
|
||||
pluginName: string,
|
||||
method: string,
|
||||
transform: (options: unknown) => unknown,
|
||||
): NativeCapability["mobile"] {
|
||||
return {
|
||||
supported: () => typeof plugin(pluginName)?.[method] === "function",
|
||||
run: (options) => plugin(pluginName)![method]!(transform(options)),
|
||||
};
|
||||
}
|
||||
|
||||
export const mobileCapabilities: Record<string, NativeCapability> = {
|
||||
camera: { mobile: capacitorAdapter("Camera", "getPhoto") },
|
||||
"clipboard.write": {
|
||||
mobile: transformedCapacitorAdapter("Clipboard", "write", (options) => {
|
||||
const value = options as { text?: unknown; string?: unknown };
|
||||
return { string: String(value?.text ?? value?.string ?? "") };
|
||||
}),
|
||||
},
|
||||
share: { mobile: capacitorAdapter("Share", "share") },
|
||||
geolocation: { mobile: capacitorAdapter("Geolocation", "getCurrentPosition") },
|
||||
network: { mobile: capacitorAdapter("Network", "getStatus") },
|
||||
haptics: { mobile: capacitorAdapter("Haptics", "impact") },
|
||||
"storage.get": { mobile: capacitorAdapter("Preferences", "get") },
|
||||
"storage.set": { mobile: capacitorAdapter("Preferences", "set") },
|
||||
"filesystem.read": { mobile: capacitorAdapter("Filesystem", "readFile") },
|
||||
"filesystem.write": { mobile: capacitorAdapter("Filesystem", "writeFile") },
|
||||
"notifications.schedule": {
|
||||
mobile: transformedCapacitorAdapter("LocalNotifications", "schedule", (options) => {
|
||||
const value = options as {
|
||||
notifications?: unknown[];
|
||||
id?: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
schedule?: unknown;
|
||||
};
|
||||
if (value?.notifications) return value;
|
||||
return {
|
||||
notifications: [
|
||||
{
|
||||
id: value?.id ?? Date.now() % 2_147_483_647,
|
||||
title: value?.title ?? "Notification",
|
||||
body: value?.body ?? "",
|
||||
schedule: value?.schedule,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
"device.info": { mobile: capacitorAdapter("Device", "getInfo") },
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
NativeAdapter,
|
||||
NativeCapability,
|
||||
NativePlatform,
|
||||
NativeRunOptions,
|
||||
NativeTarget,
|
||||
NativeBrowserRuntime,
|
||||
} from "./types.ts";
|
||||
|
||||
export class NativeUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NativeUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function capacitor():
|
||||
| {
|
||||
isNativePlatform?: () => boolean;
|
||||
getPlatform?: () => string;
|
||||
}
|
||||
| undefined {
|
||||
if (typeof globalThis === "undefined") return undefined;
|
||||
return (globalThis as typeof globalThis & { Capacitor?: object }).Capacitor;
|
||||
}
|
||||
|
||||
export function isMobile(): boolean {
|
||||
const bridge = capacitor();
|
||||
return (
|
||||
bridge?.isNativePlatform?.() === true ||
|
||||
["ios", "android"].includes(bridge?.getPlatform?.() ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
export function platform(): NativePlatform {
|
||||
const bridge = capacitor();
|
||||
if (isMobile()) return bridge?.getPlatform?.() ?? "webview";
|
||||
return typeof window === "undefined" ? "server" : "browser";
|
||||
}
|
||||
|
||||
const capabilities = new Map<string, NativeCapability>();
|
||||
|
||||
function browserRuntime(): NativeBrowserRuntime | undefined {
|
||||
if (typeof globalThis === "undefined") return undefined;
|
||||
return (globalThis as typeof globalThis & { WrNexusNative?: NativeBrowserRuntime }).WrNexusNative;
|
||||
}
|
||||
|
||||
function pendingRegistrations(): Map<string, NativeCapability> | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const root = globalThis as typeof globalThis & {
|
||||
__WrNexusNativePending?: Map<string, NativeCapability>;
|
||||
};
|
||||
return (root.__WrNexusNativePending ??= new Map());
|
||||
}
|
||||
|
||||
export function register<TOptions = unknown, TResult = unknown>(
|
||||
name: string,
|
||||
capability: NativeCapability<TOptions, TResult>,
|
||||
): () => void {
|
||||
if (!/^[a-z][a-z0-9.-]*$/i.test(name))
|
||||
throw new TypeError(`Invalid native capability name: ${name}`);
|
||||
const value = capability as NativeCapability;
|
||||
capabilities.set(name, value);
|
||||
const runtime = browserRuntime();
|
||||
if (runtime) runtime.register(name, value);
|
||||
else pendingRegistrations()?.set(name, value);
|
||||
return () => {
|
||||
capabilities.delete(name);
|
||||
const activeRuntime = browserRuntime();
|
||||
if (activeRuntime) activeRuntime.unregister(name);
|
||||
else pendingRegistrations()?.delete(name);
|
||||
};
|
||||
}
|
||||
|
||||
export function registered(): string[] {
|
||||
return [...capabilities.keys()].sort();
|
||||
}
|
||||
|
||||
function adapter(name: string, target: NativeTarget): NativeAdapter | undefined {
|
||||
return capabilities.get(name)?.[target];
|
||||
}
|
||||
|
||||
export function supports(
|
||||
name: string,
|
||||
target: NativeTarget = isMobile() ? "mobile" : "browser",
|
||||
): boolean {
|
||||
const value = adapter(name, target);
|
||||
return !!value && (value.supported?.() ?? true);
|
||||
}
|
||||
|
||||
export async function run<TResult = unknown>(
|
||||
name: string,
|
||||
options?: unknown,
|
||||
runOptions: NativeRunOptions = {},
|
||||
): Promise<TResult> {
|
||||
const target = runOptions.target ?? (isMobile() ? "mobile" : "browser");
|
||||
const value = adapter(name, target);
|
||||
if (!value || !(value.supported?.() ?? true)) {
|
||||
throw new NativeUnavailableError(`Native capability "${name}" is unavailable on ${target}`);
|
||||
}
|
||||
return (await value.run(options)) as TResult;
|
||||
}
|
||||
|
||||
export function clearRegistry(): void {
|
||||
capabilities.clear();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type NativePlatform = "server" | "browser" | "webview" | "ios" | "android" | "expo" | string;
|
||||
export type NativeTarget = "browser" | "mobile";
|
||||
|
||||
export interface NativeAdapter<TOptions = unknown, TResult = unknown> {
|
||||
supported?: () => boolean;
|
||||
run(options?: TOptions): TResult | Promise<TResult>;
|
||||
}
|
||||
|
||||
export interface NativeCapability<TOptions = unknown, TResult = unknown> {
|
||||
browser?: NativeAdapter<TOptions, TResult>;
|
||||
mobile?: NativeAdapter<TOptions, TResult>;
|
||||
}
|
||||
|
||||
export interface NativeRunOptions {
|
||||
target?: NativeTarget;
|
||||
}
|
||||
|
||||
export interface NativeBrowserRuntime {
|
||||
target: NativeTarget;
|
||||
isMobile: boolean;
|
||||
register(name: string, capability: NativeCapability): void;
|
||||
unregister(name: string): void;
|
||||
supports(name: string): boolean;
|
||||
run<TResult = unknown>(name: string, options?: unknown): Promise<TResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user