Ctrl K
ComunidadGuías…

electron/cloud-buy.mjs

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

Archivo original ↗Preguntar a KiBot
// The buying sheet's main-process side: the email sign-in, the kept checkout page, the Cloud's name and colour, and
// "{name} is ready". Every handler takes the page's words as plain text and checks them here; no token, checkout
// address or account leaves main (Copy link writes the clipboard from here).
import { deflateSync } from "node:zlib";
import environments from "./environments.cjs";
import { CHECKOUT_SOURCES } from "./cloud-account.mjs";
import { CLOUD_HOME_NAME } from "./cloud-home.mjs";
import nativeText from "./native-text.cjs";

export const CLOUD_COLORS = environments.ENTRY_COLORS;
export const DEFAULT_CLOUD_COLOR = "blue";
export const CLOUD_NAME_MAX = 28;
/** Each colour in the default skin (src/lib/mascot.ts MAUS_COLORS), for the native menu's dot; the page uses the skin's own. */
export const CLOUD_COLOR_HEX = Object.freeze({ blue: "#377FE6", teal: "#01A492", purple: "#8057C8", orange: "#E78531", red: "#D94B52", yellow: "#D8A729" });
// oxlint-disable-next-line no-control-regex
const CONTROL = /[\x00-\x1f\x7f]/;

export function cleanCloudName(value) {
  if (typeof value !== "string") return null;
  const name = value.trim().replace(/\s+/g, " ");
  return name && [...name].length <= CLOUD_NAME_MAX && !CONTROL.test(name) ? name : null;
}
export const cleanCloudColor = value => (CLOUD_COLORS.includes(value) ? value : null);

const CRC = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
const crc32 = bytes => { let c = 0xffffffff; for (const byte of bytes) c = CRC[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const pngChunk = (type, data) => {
  const out = Buffer.alloc(12 + data.length);
  out.writeUInt32BE(data.length, 0); out.write(type, 4, "latin1"); data.copy(out, 8);
  out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
  return out;
};
/** A filled circle with a soft edge, as a PNG data URL (straight RGBA, so no platform's pixel order matters). */
export function colorDotPng(hex, size, diameter = size * 0.72) {
  const [red, green, blue] = [1, 3, 5].map(at => Number.parseInt(hex.slice(at, at + 2), 16));
  const rows = Buffer.alloc(size * (size * 4 + 1)), center = size / 2, radius = diameter / 2;
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const alpha = Math.max(0, Math.min(1, radius + 0.5 - Math.hypot(x + 0.5 - center, y + 0.5 - center)));
      rows.set([red, green, blue, Math.round(alpha * 255)], y * (size * 4 + 1) + 1 + x * 4);
    }
  }
  const header = Buffer.alloc(13);
  header.writeUInt32BE(size, 0); header.writeUInt32BE(size, 4); header.set([8, 6, 0, 0, 0], 8);
  const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), pngChunk("IHDR", header), pngChunk("IDAT", deflateSync(rows)), pngChunk("IEND", Buffer.alloc(0))]);
  return `data:image/png;base64,${png.toString("base64")}`;
}

/** The native switcher's dot for a saved colour (14 pt, sharp at 2x), drawn once per colour; null where it cannot be drawn. */
export function colorDotIcons(nativeImage) {
  const made = new Map();
  return color => {
    if (!cleanCloudColor(color)) return null;
    if (!made.has(color)) {
      try {
        const image = nativeImage.createEmpty();
        for (const scaleFactor of [1, 2]) image.addRepresentation({ scaleFactor, dataURL: colorDotPng(CLOUD_COLOR_HEX[color], 14 * scaleFactor) });
        made.set(color, image.isEmpty() ? null : image);
      } catch { made.set(color, null); }
    }
    return made.get(color) ?? null;
  };
}

/** The name and colour chosen while the Cloud is set up, kept for its account until the Cloud is listed (userData). */
export function lookFile(file, fs) {
  let cached;
  return {
    read() {
      if (cached !== undefined) return cached;
      try {
        const saved = JSON.parse(fs.readFileSync(file(), "utf8"));
        cached = typeof saved?.accountId === "string" && cleanCloudName(saved.name) && cleanCloudColor(saved.color)
          ? { accountId: saved.accountId, name: cleanCloudName(saved.name), color: saved.color } : null;
      } catch { cached = null; }
      return cached;
    },
    write(value) {
      cached = value;
      try { fs.writeFileSync(file(), JSON.stringify(value), { mode: 0o600 }); } catch {}
    },
  };
}

/** One answer to "what is this Cloud called, in which colour": its Servers entry once listed, else what was chosen, else My Cloud. */
export function createCloudLook({ store, accountId, homeEntry, saveEntry }) {
  const chosen = () => { const saved = store.read(); return saved && saved.accountId === accountId() ? { name: saved.name, color: saved.color } : null; };
  const current = () => {
    const entry = homeEntry();
    if (entry) return { name: entry.name, color: cleanCloudColor(entry.color) ?? DEFAULT_CLOUD_COLOR };
    return chosen() ?? { name: CLOUD_HOME_NAME, color: DEFAULT_CLOUD_COLOR };
  };
  return {
    /** For the Servers entry made when the Cloud is ready (cloud-home.mjs withCloudHome). */
    pending: chosen,
    current,
    save(name, color) {
      const look = { name: cleanCloudName(name), color: cleanCloudColor(color) };
      if (!look.name || !look.color) throw new Error(`Choose a name of up to ${CLOUD_NAME_MAX} characters and one of the colours.`);
      const id = accountId();
      if (id) store.write({ accountId: id, ...look });
      const entry = homeEntry();
      if (entry) saveEntry(entry.origin, look);
      return current();
    },
  };
}

/** openmausbot://cloud, the same with or without a checkout waiting: the sheet while a purchase made here is under way
 * (a checkout opened in the last day, a payment being linked, setup), else Settings, which signs in or opens the Cloud. */
export function cloudLinkOpensSheet(state) {
  return Boolean((state?.checkout && !state.checkout.done) || (state?.adding && !state.adding.done) || state?.purchase || state?.machine?.status === "provisioning");
}

/** Paid, and the Cloud still being made: a payment linked, no machine yet, or one setting up. */
const settingUp = state => state?.status === "connected" && (Boolean(state.purchase) || state.machine?.status === "provisioning"
  || (state.entitlement?.status === "active" && !state.machine));

/**
 * "{name} is ready", once per checkout this app opened, or once for a setup this app watched (bought elsewhere, or the
 * app restarted mid-setup). Not when the sheet is open in the focused window: the sheet opens the Cloud itself
 * ("Opening in 3…"). A click opens the Cloud.
 */
export function createCloudReadyNotice({ Notification, focused, sheetOpen, name, open, shown = () => {}, t = nativeText.englishText.t }) {
  let announced = null, watched = false;
  return state => {
    if (settingUp(state)) { watched = true; return false; }
    const key = state?.checkout?.startedAt ?? (watched ? "watched-setup" : null);
    if (!key || state.status !== "connected" || state.machine?.status !== "ready" || announced === key) return false;
    announced = key; watched = false;
    if ((focused() && sheetOpen()) || !Notification.isSupported()) return false;
    const note = new Notification({ title: t("native.cloud.ready.title", { name: name() }), body: t("native.cloud.ready.body") });
    note.on("click", () => open());
    note.show();
    shown();
    return true;
  };
}

const plain = value => (typeof value === "string" ? value : "");
/** The sheet's own channels, each behind main's local-window guard. */
export function installCloudBuy({ ipcMain, guard, client, offersReady, clipboard, look }) {
  let sheetOpen = false;
  // A page that navigates away (Open {name} loads the Cloud) never says it closed: that navigation closes it.
  const watched = new WeakSet();
  const handle = (method, handler) => ipcMain.handle(`cloud-account:${method}`, guard(`cloud-account:${method}`, handler));
  handle("signInStart", (_event, email) => client().emailStart(plain(email)));
  handle("signInVerify", (_event, code) => client().emailVerify(plain(code)));
  handle("signInCancel", () => { client().emailCancel(); });
  handle("reopenCheckout", async (_event, plan, source) => {
    if (!(await offersReady())) throw new Error("MausBot Cloud isn't offered in this app.");
    const account = client();
    if (account.checkoutLink()) return account.reopenCheckout();
    if (typeof plan !== "string" || !CHECKOUT_SOURCES.includes(source)) throw new Error("Choose a plan to continue.");
    const offer = await account.offer();
    if (!offer?.plans.some(entry => entry.tier === plan)) throw new Error("Choose a plan to continue.");
    return account.reopenCheckout(plan, source);
  });
  handle("copyCheckoutLink", () => {
    const url = client().checkoutLink();
    if (url) clipboard.writeText(url);
    return Boolean(url);
  });
  handle("interest", () => client().interest("another_cloud"));
  handle("look", () => look.current());
  handle("setLook", (_event, name, color) => look.save(plain(name), plain(color)));
  handle("readySeen", () => { client().checkoutDone(); });
  handle("sheet", (event, open) => {
    sheetOpen = open === true;
    const page = event?.sender;
    if (sheetOpen && typeof page?.on === "function" && !watched.has(page)) {
      watched.add(page);
      for (const done of ["did-navigate", "destroyed"]) page.on(done, () => { sheetOpen = false; });
    }
  });
  return { sheetOpen: () => sheetOpen };
}