Ctrl K
ComunidadGuías…

mobile/native/bridge.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Native bridge for the Cortex mobile app.
//
// The web app (mobile/src) never imports Capacitor. Instead this file is
// bundled by scripts/mobile/build-bridge.mjs into mobile/dist/native-bridge.js
// and injected as a classic <script> ahead of the SPA's module script, so by
// the time React boots `window.CortexNative` exists on a phone and is
// `undefined` in a plain browser / PWA. The client feature-detects it.
//
// Contract (superset of what mobile/src/lib/native.ts consumes — keep in sync):
//   platform, haptic(kind), scanQr(), getSecret/setSecret(key, value|null),
//   secureGet/secureSet/secureDelete(key) (aliases), deviceName(),
//   openExternal(url), share(text), setStatusBar(style)
// plus additive extras (onDeepLink, getLaunchUrl, hideSplash, hideKeyboard).
// DOM events on window: "cortex:deeplink" (CustomEvent<string> — the URL,
// also fired for the cold-start URL after load), "cortex:resume",
// "cortex:pause".
//
// `secure*` is backed by @capacitor/preferences (UserDefaults / SharedPrefs):
// it is per-app sandboxed but NOT encrypted at rest. The upgrade path is a
// keychain/keystore plugin (e.g. capacitor-secure-storage-plugin) behind the
// same three functions — nothing in the client has to change.

import { Capacitor } from "@capacitor/core";
import { App } from "@capacitor/app";
import { Browser } from "@capacitor/browser";
import { Device } from "@capacitor/device";
import { Haptics, ImpactStyle, NotificationType } from "@capacitor/haptics";
import { Keyboard } from "@capacitor/keyboard";
import { Preferences } from "@capacitor/preferences";
import { Share } from "@capacitor/share";
import { SplashScreen } from "@capacitor/splash-screen";
import { StatusBar, Style } from "@capacitor/status-bar";
import {
  BarcodeFormat,
  BarcodeScanner,
} from "@capacitor-mlkit/barcode-scanning";

export type HapticKind =
  | "light"
  | "medium"
  | "heavy"
  | "success"
  | "warning"
  | "error"
  | "selection";

export type StatusBarStyle = "light" | "dark";

export interface CortexNativeBridge {
  /** "ios" | "android" (never "web": the bridge is only installed natively). */
  platform: "ios" | "android";
  /** Bridge/app version (mirrors the root package.json version at build time). */
  version: string;
  haptic(kind: HapticKind): Promise<void>;
  /** Open the camera QR scanner; resolves with the raw QR text or null if cancelled/unavailable. */
  scanQr(): Promise<string | null>;
  getSecret(key: string): Promise<string | null>;
  /** `null` deletes the key. */
  setSecret(key: string, value: string | null): Promise<void>;
  /** Aliases of getSecret/setSecret/setSecret(key, null). */
  secureGet(key: string): Promise<string | null>;
  secureSet(key: string, value: string): Promise<void>;
  secureDelete(key: string): Promise<void>;
  /** Human device name for pairing, e.g. "Connor's iPhone" / "Pixel 8". */
  deviceName(): Promise<string>;
  /** Open a URL in the system browser (SFSafariViewController / Custom Tab). */
  openExternal(url: string): Promise<void>;
  share(text: string, title?: string): Promise<void>;
  setStatusBar(style: StatusBarStyle): Promise<void>;
  // --- extras -------------------------------------------------------------
  /** Subscribe to `cortex://...` deep links opened while the app is running. Returns an unsubscribe fn. */
  onDeepLink(cb: (url: string) => void): () => void;
  /** The `cortex://...` URL the app was cold-started with, if any. */
  getLaunchUrl(): Promise<string | null>;
  /** Hide the splash screen early (it auto-hides after ~600 ms anyway). */
  hideSplash(): Promise<void>;
  /** Dismiss the soft keyboard. */
  hideKeyboard(): Promise<void>;
}

declare global {
  interface Window {
    CortexNative?: CortexNativeBridge;
  }
}

declare const __CORTEX_VERSION__: string;

const PREF_PREFIX = "cortex.secure.";

function swallow(p: Promise<unknown>): Promise<void> {
  return p.then(
    () => undefined,
    (e) => {
      console.warn("[CortexNative]", e);
    },
  );
}

async function haptic(kind: HapticKind): Promise<void> {
  switch (kind) {
    case "light":
      return swallow(Haptics.impact({ style: ImpactStyle.Light }));
    case "medium":
      return swallow(Haptics.impact({ style: ImpactStyle.Medium }));
    case "heavy":
      return swallow(Haptics.impact({ style: ImpactStyle.Heavy }));
    case "success":
      return swallow(Haptics.notification({ type: NotificationType.Success }));
    case "warning":
      return swallow(Haptics.notification({ type: NotificationType.Warning }));
    case "error":
      return swallow(Haptics.notification({ type: NotificationType.Error }));
    case "selection":
      return swallow(Haptics.selectionChanged());
    default:
      return;
  }
}

/**
 * Last-resort QR path when the ML Kit plugin is missing or fails: take a photo
 * with the system camera (`<input capture>`) and decode it with the browser's
 * BarcodeDetector (available in the Android System WebView; absent on iOS,
 * where the plugin is the only route). Resolves null when nothing decodes.
 */
function scanQrFallback(): Promise<string | null> {
  type Detector = {
    detect(src: ImageBitmapSource): Promise<Array<{ rawValue: string }>>;
  };
  type DetectorCtor = new (opts: { formats: string[] }) => Detector;
  const Ctor = (window as unknown as { BarcodeDetector?: DetectorCtor })
    .BarcodeDetector;
  if (!Ctor) return Promise.resolve(null);

  return new Promise((resolve) => {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = "image/*";
    input.setAttribute("capture", "environment");
    input.style.display = "none";
    let settled = false;
    const finish = (v: string | null) => {
      if (settled) return;
      settled = true;
      input.remove();
      resolve(v);
    };
    input.addEventListener("change", async () => {
      const file = input.files?.[0];
      if (!file) return finish(null);
      try {
        const bitmap = await createImageBitmap(file);
        const found = await new Ctor({ formats: ["qr_code"] }).detect(bitmap);
        bitmap.close();
        finish(found[0]?.rawValue ?? null);
      } catch (e) {
        console.warn("[CortexNative] BarcodeDetector failed", e);
        finish(null);
      }
    });
    // The picker was dismissed without a file: `change` never fires, but
    // focus returns to the page. Give iOS/Android a beat to deliver `change`.
    window.addEventListener(
      "focus",
      () => {
        setTimeout(() => finish(null), 1500);
      },
      { once: true },
    );
    document.body.appendChild(input);
    input.click();
  });
}

async function scanQr(): Promise<string | null> {
  if (!Capacitor.isPluginAvailable("BarcodeScanner")) {
    return scanQrFallback();
  }
  try {
    const { supported } = await BarcodeScanner.isSupported();
    if (!supported) return scanQrFallback();

    if (Capacitor.getPlatform() === "android") {
      // `scan()` on Android uses the Google code-scanner module (no camera
      // permission needed); it may have to be downloaded once.
      const { available } =
        await BarcodeScanner.isGoogleBarcodeScannerModuleAvailable();
      if (!available) {
        await BarcodeScanner.installGoogleBarcodeScannerModule();
        // Installation is async; the plugin fires a progress event. Fall back
        // for this attempt so the user isn't left waiting.
        return scanQrFallback();
      }
    } else {
      const perm = await BarcodeScanner.requestPermissions();
      if (perm.camera !== "granted" && perm.camera !== "limited") return null;
    }

    const { barcodes } = await BarcodeScanner.scan({
      formats: [BarcodeFormat.QrCode],
    });
    const first = barcodes[0];
    return first ? first.rawValue || first.displayValue || null : null;
  } catch (e) {
    // User cancelled → the plugin rejects; treat as "no result".
    const msg = String((e as { message?: string })?.message ?? e).toLowerCase();
    if (msg.includes("cancel")) return null;
    console.warn("[CortexNative] scan failed, trying fallback", e);
    return scanQrFallback();
  }
}

async function secureGet(key: string): Promise<string | null> {
  const { value } = await Preferences.get({ key: PREF_PREFIX + key });
  return value ?? null;
}

function secureSet(key: string, value: string): Promise<void> {
  return Preferences.set({ key: PREF_PREFIX + key, value });
}

function secureDelete(key: string): Promise<void> {
  return Preferences.remove({ key: PREF_PREFIX + key });
}

function setSecret(key: string, value: string | null): Promise<void> {
  return value === null ? secureDelete(key) : secureSet(key, value);
}

async function deviceName(): Promise<string> {
  try {
    const info = await Device.getInfo();
    // `name` is the user-set name on iOS (and Android 7.1+ where exposed);
    // otherwise fall back to manufacturer + model.
    if (info.name) return info.name;
    const parts = [info.manufacturer, info.model].filter(Boolean);
    if (parts.length) return parts.join(" ");
    return info.platform === "ios" ? "iPhone" : "Android phone";
  } catch {
    return Capacitor.getPlatform() === "ios" ? "iPhone" : "Android phone";
  }
}

async function openExternal(url: string): Promise<void> {
  if (!/^https?:\/\//i.test(url)) {
    // mailto:, tel:, other apps' schemes → let the OS route it.
    window.open(url, "_system");
    return;
  }
  await Browser.open({ url, presentationStyle: "popover" });
}

async function share(text: string, title?: string): Promise<void> {
  const { value: canShare } = await Share.canShare();
  if (!canShare) {
    await navigator.clipboard?.writeText(text);
    return;
  }
  try {
    await Share.share({ title, text, dialogTitle: title });
  } catch (e) {
    // Dismissing the share sheet rejects on some OS versions; not an error.
    console.debug("[CortexNative] share dismissed", e);
  }
}

async function setStatusBar(style: StatusBarStyle): Promise<void> {
  // "dark" = dark background, light icons (Style.Dark); "light" = the reverse.
  await swallow(
    StatusBar.setStyle({ style: style === "dark" ? Style.Dark : Style.Light }),
  );
  if (Capacitor.getPlatform() === "android") {
    await swallow(
      StatusBar.setBackgroundColor({
        color: style === "dark" ? "#0a0a0b" : "#ffffff",
      }),
    );
  }
}

const deepLinkListeners = new Set<(url: string) => void>();

function onDeepLink(cb: (url: string) => void): () => void {
  deepLinkListeners.add(cb);
  return () => {
    deepLinkListeners.delete(cb);
  };
}

async function getLaunchUrl(): Promise<string | null> {
  try {
    const res = await App.getLaunchUrl();
    return res?.url ?? null;
  } catch {
    return null;
  }
}

function install(): void {
  const platform = Capacitor.getPlatform();
  if (platform !== "ios" && platform !== "android") {
    // Running in a normal browser (e.g. `vite preview` with the bridge
    // injected): leave `window.CortexNative` undefined so the client takes
    // its web paths.
    return;
  }

  const bridge: CortexNativeBridge = {
    platform,
    version:
      typeof __CORTEX_VERSION__ === "string" ? __CORTEX_VERSION__ : "0.0.0",
    haptic,
    scanQr,
    getSecret: secureGet,
    setSecret,
    secureGet,
    secureSet,
    secureDelete,
    deviceName,
    openExternal,
    share,
    setStatusBar,
    onDeepLink,
    getLaunchUrl,
    hideSplash: () => swallow(SplashScreen.hide()),
    hideKeyboard: () => swallow(Keyboard.hide()),
  };
  window.CortexNative = bridge;

  // Deep links (`cortex://pair?...`, `cortex://inbox`) arriving while running.
  // Also re-broadcast as a DOM event so code that doesn't hold the bridge can
  // listen with `window.addEventListener("cortex:deeplink", ...)`.
  void App.addListener("appUrlOpen", ({ url }: { url: string }) => {
    for (const cb of deepLinkListeners) {
      try {
        cb(url);
      } catch (e) {
        console.warn("[CortexNative] deep link handler threw", e);
      }
    }
    window.dispatchEvent(
      new CustomEvent("cortex:deeplink", { detail: { url } }),
    );
  });

  // Android hardware back: walk the SPA's history, exit only from the root.
  if (platform === "android") {
    void App.addListener(
      "backButton",
      ({ canGoBack }: { canGoBack: boolean }) => {
        if (canGoBack) {
          window.history.back();
        } else {
          void App.exitApp();
        }
      },
    );
  }

  void setStatusBar("dark");
}

install();