Ctrl K
ComunidadGuías…

electron/desktop-prefs.mjs

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

Archivo original ↗Preguntar a KiBot
// What this desktop keeps whatever page its window shows, whichever port its own page is served on: the notices someone
// closed, its usage-analytics switch, the app's language, its answer to the product-updates email question, whether
// the person hid the window's rail of places and whether they turned the window's frame off.
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import nativeText from "./native-text.cjs";
import { onboardingHints } from "./onboarding-record.mjs";
import { DEFAULT_TALK_KEY, isTalkKey } from "./talk-keys.mjs";
import { createUpdatesConsent, parseUpdatesConsent, registerUpdatesConsent } from "./updates-consent.mjs";
import { accountFullName } from "./system-name.mjs";

export { DESKTOP_PORTS } from "./desktop-ports.mjs";
/** The only notices a page may record, so no page can write anything else into the file. */
export const DESKTOP_NOTICES = Object.freeze(["whats-new-2026-11", "boat-credit-2026-11", "grok-move-2026-11", "modelgo-offer-2026-11"]);
const WHATS_NEW = DESKTOP_NOTICES[0];
/** A locale code such as "de" or "pt-br", or "" for following the system. */
const LANGUAGE = /^(?:[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,2})?$/i;
const isLanguage = value => typeof value === "string" && value.length <= 24 && LANGUAGE.test(value);
const CHAT_ID = /^[\w-]{1,128}$/;
const INSTALL_ID = /^[A-Za-z0-9_-]{22,64}$/;

/** Security: a write that fails (a full disk, a kill mid-write) leaves the last whole record, analytics opt-out included.
 * A file another tool saved with a byte-order mark (Notepad on Windows) still reads. */
export function jsonFile(file, files = fs) {
  return {
    read: () => JSON.parse(files.readFileSync(file, "utf8").replace(/^\uFEFF/, "")),
    write: value => {
      const temporary = `${file}.${process.pid}.tmp`;
      try {
        files.writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 });
        files.renameSync(temporary, file);
      } catch (error) {
        try { files.rmSync(temporary, { force: true }); } catch {}
        throw error;
      }
    },
  };
}

const parseChat = value => value && typeof value === "object" && typeof value.botId === "string" && typeof value.threadId === "string"
  && CHAT_ID.test(value.botId) && CHAT_ID.test(value.threadId) ? { botId: value.botId, threadId: value.threadId } : null;

function parse(raw) {
  const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
  const seen = Array.isArray(record.seen) ? DESKTOP_NOTICES.filter(id => record.seen.includes(id)) : [];
  const updatesConsent = parseUpdatesConsent(record.updatesConsent);
  const quickAsks = record.quickAsks && typeof record.quickAsks === "object" && !Array.isArray(record.quickAsks)
    ? Object.fromEntries(Object.entries(record.quickAsks).flatMap(([server, chat]) => { const parsed = parseChat(chat); return parsed ? [[server, parsed]] : []; }))
    : {};
  return { seen, ...(typeof record.analytics === "boolean" ? { analytics: record.analytics } : {}),
    ...(isLanguage(record.language) ? { language: record.language } : {}),
    ...(typeof record.installId === "string" && INSTALL_ID.test(record.installId) ? { installId: record.installId } : {}),
    ...(updatesConsent ? { updatesConsent } : {}),
    ...(typeof record.talkShortcut === "boolean" ? { talkShortcut: record.talkShortcut } : {}),
    ...(isTalkKey(record.talkKey) ? { talkKey: record.talkKey } : {}),
    ...(typeof record.railHidden === "boolean" ? { railHidden: record.railHidden } : {}),
    ...(typeof record.windowFrame === "boolean" ? { windowFrame: record.windowFrame } : {}),
    ...(Object.keys(quickAsks).length ? { quickAsks } : {}) };
}

/** Main's own words in the language this desktop's record keeps, with that record as `prefs`. Electron knows the
 * system's language only once the app is ready. */
export function createDesktopText(app, prefs = createDesktopPrefs(jsonFile(path.join(app.getPath("userData"), "desktop-prefs.json")))) {
  return Object.assign(nativeText.createNativeText({ saved: prefs.language(), system: () => (app.isReady() ? app.getLocale() : "") }), { prefs });
}

export function relabelDesktop({ menu, trays, window, title, badge }) {
  menu();
  for (const tray of trays) tray?.relabel();
  if (window && !window.isDestroyed()) window.setTitle(title());
  badge();
}

/** A record that cannot be written is still kept for this launch. */
export function createDesktopPrefs({ read, write }) {
  let record = null;
  const current = () => {
    if (record) return record;
    let raw = null;
    try { raw = read(); } catch {}
    return (record = parse(raw));
  };
  const save = next => { record = next; try { write(next); } catch {} };
  return {
    seen: () => [...current().seen],
    markSeen(id) {
      const saved = current();
      if (DESKTOP_NOTICES.includes(id) && !saved.seen.includes(id)) save({ ...saved, seen: [...saved.seen, id] });
    },
    /** null until a choice is saved. */
    analytics: () => current().analytics ?? null,
    setAnalytics(enabled) { save({ ...current(), analytics: enabled === true }); },
    /** null until a language is picked; "" is a pick too: follow the system. */
    language: () => current().language ?? null,
    setLanguage(language) {
      if (!isLanguage(language)) return false;
      if (current().language !== language) save({ ...current(), language });
      return true;
    },
    /** Random, made on first use: the only name the Admin's no-account consent endpoint counts this computer by. */
    installId() {
      const saved = current();
      if (saved.installId) return saved.installId;
      const installId = randomBytes(24).toString("base64url");
      save({ ...saved, installId });
      return installId;
    },
    updatesConsent: () => current().updatesConsent ?? null,
    setUpdatesConsent(value) {
      const updatesConsent = parseUpdatesConsent(value);
      if (!updatesConsent) return false;
      save({ ...current(), updatesConsent });
      return true;
    },
    talkShortcut: () => current().talkShortcut ?? true,
    setTalkShortcut(enabled) { save({ ...current(), talkShortcut: enabled === true }); },
    talkKey: () => current().talkKey ?? DEFAULT_TALK_KEY,
    setTalkKey(id) {
      if (!isTalkKey(id)) return false;
      if (current().talkKey !== id) save({ ...current(), talkKey: id });
      return true;
    },
    quickAsksChat(server) {
      const quickAsks = current().quickAsks;
      return quickAsks && Object.hasOwn(quickAsks, server) ? { ...quickAsks[server] } : null;
    },
    setQuickAsksChat(server, chat) {
      const parsed = typeof server === "string" ? parseChat(chat) : null;
      if (parsed) save({ ...current(), quickAsks: { ...current().quickAsks, [server]: parsed } });
    },
    /** The window's rail of places is shown until the person hides it. */
    railHidden: () => current().railHidden === true,
    setRailHidden(hidden) {
      const railHidden = hidden === true;
      if ((current().railHidden === true) !== railHidden) save({ ...current(), railHidden });
    },
    /** The window draws its own frame until the person turns it off. */
    windowFrame: () => current().windowFrame !== false,
    setWindowFrame(on) {
      const windowFrame = on !== false;
      if ((current().windowFrame !== false) !== windowFrame) save({ ...current(), windowFrame });
    },
  };
}

/**
 * `cloudAccount()`: the MausBot Cloud sign-in client, for the product-updates question (updates-consent.mjs), which is
 * asked where Cloud offers are (`cloudOffered`); without it nothing is asked and null is returned.
 * `page(event)`: "local" for this computer's page, "cloud" for My Cloud's own page as main verifies it, "server" for
 * another saved server open in this desktop's window, null for anything else. `cloudOffered()`: this desktop sells
 * MausBot Cloud, asked once its Cloud sign-in is restored. `localConfig()`: This computer's own workspace's config.json.
 * `languageChanged(language, sender)`: tells this desktop's other pages the language picked on one of them;
 * `onLanguage(language)`: tells main, which draws its menus, tray and dialogs in it. `onShown(language, sender)`: the
 * language a page of the window shows, which main's words follow while nothing is picked. `prefs`: the record main reads too.
 */
export function registerDesktopPrefs({ prefs: given, ipcMain, read, write, page, cloudOffered, cloudAccount, localConfig = () => null, languageChanged = () => {}, onLanguage = () => {}, onShown = () => {}, frame = null, now = Date.now, systemName = accountFullName }) {
  let prefs = given ?? null;
  const store = () => prefs ??= createDesktopPrefs({ read, write });
  const from = (channel, pages, handler) => (event, ...args) => {
    const asking = page(event);
    if (!pages.includes(asking)) throw new Error(`${channel} is only available in this app's window`);
    return handler(asking, ...args);
  };
  // This computer's workspace record counts on every page, before This computer's page has loaded.
  const state = async () => {
    const cloud = await cloudOffered();
    let hints = [];
    try { hints = onboardingHints(localConfig()); } catch {}
    const seen = store().seen(), closed = hints.includes(WHATS_NEW);
    return { seen: closed && !seen.includes(WHATS_NEW) ? [...seen, WHATS_NEW] : seen, cloud };
  };
  const ANY = ["local", "cloud", "server"];
  ipcMain.handle("desktop-notices:state", from("desktop-notices:state", ANY, state));
  ipcMain.handle("desktop-notices:seen", from("desktop-notices:seen", ANY, (_, id) => store().markSeen(id)));
  // A first run's default name for the profile: this computer's own page only, never a server's.
  ipcMain.handle("desktop-profile:system-name", from("desktop-profile:system-name", ["local"], () => systemName()));
  // Security: a server's page learns only that this desktop turned analytics off, never that it is on.
  ipcMain.on("desktop-analytics:read", event => {
    try {
      const asking = page(event), saved = store().analytics();
      event.returnValue = asking === "local" ? saved : asking && saved === false ? false : null;
    } catch { event.returnValue = null; }
  });
  ipcMain.handle("desktop-analytics:set", from("desktop-analytics:set", ["local"], (_, enabled) => { store().setAnalytics(enabled === true); }));
  ipcMain.on("desktop-language:read", event => {
    try { event.returnValue = page(event) ? store().language() : null; } catch { event.returnValue = null; }
  });
  const setLanguage = from("desktop-language:set", ANY, (_, language) => {
    const before = store().language();
    return store().setLanguage(language) && before !== language;
  });
  ipcMain.handle("desktop-language:set", (event, language) => {
    if (setLanguage(event, language)) {
      try { languageChanged(language, event.sender); } catch {}
      try { onLanguage(language); } catch {}
    }
    return store().language() === language;
  });
  ipcMain.on("desktop-language:shown", (event, language) => {
    try { if (page(event) && isLanguage(language) && language) onShown(language, event.sender); } catch {}
  });
  // Every page of this desktop's window reads and sets the frame; a server's page sets it only from the person's click,
  // which the preload checks.
  const frameState = () => ({ on: store().windowFrame(), restart: Boolean(frame?.restarts) && frame.made() !== null && frame.made() !== store().windowFrame() });
  ipcMain.handle("window-frame:state", from("window-frame:state", ANY, () => frameState()));
  ipcMain.handle("window-frame:set", from("window-frame:set", ANY, (_, on) => {
    store().setWindowFrame(on === true);
    try { frame?.changed(); } catch {}
    return frameState();
  }));
  if (!cloudAccount) return null;
  const consent = createUpdatesConsent({ prefs: { installId: () => store().installId(), updatesConsent: () => store().updatesConsent(), setUpdatesConsent: value => store().setUpdatesConsent(value) },
    account: cloudAccount, available: cloudOffered, now });
  registerUpdatesConsent({ ipcMain, page, consent });
  void cloudOffered().then(offered => offered && consent.sync()).catch(() => {});
  return consent;
}