Ctrl K
ComunidadGuías…

electron/cloud-rail.mjs

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

Archivo original ↗Preguntar a KiBot
import nativeText from "./native-text.cjs";
// The rail of places at the left of the window (the renderer's PlacesRail, drawn by the window's own chrome rail page,
// chrome-rail.mjs) and its +: main builds the list, says who may read it, and does what a circle or + asks through
// main's own switch and dialogs.
//
// Security: the list carries names, colours and states, never an address, and goes only to the rail's own page, this
// app's. No place's page reads it, not even this computer's or a Cloud's: a server the person connected by hand runs a
// page anyone may have written, and would learn the names of the person's other places. A page learns only that the
// rail stands beside it. A circle names a saved place by its id; main opens or removes only an id it listed itself, and
// the Cloud it acts on is main's own word for that circle (`cloudId`, which the page never sees).
import environments from "./environments.cjs";
import { CHECKOUT_SOURCES } from "./cloud-account.mjs";
import { cleanCloudListName, CLOUD_SWATCHES, cloudSetupProgress, cloudState, olderRelease, otherOwnClouds, ownCloud, signedInServers, stepProgress, swatchColor } from "./cloud-list.mjs";
import { machineSetup } from "./cloud-home.mjs";
import { listedLook, placeLook, railPaint, SIGNED_IN } from "./place-look.mjs";
import { withAccounts } from "./rail-accounts.mjs";

export const RAIL_MIN_PLACES = 2;
const PENDING = "pending:";
/** A circle for a workspace of this computer's own (a profile, clean-workspace.mjs), by the profile's id. */
export const PROFILE_PLACE = "profile:";

/** A server or computer of the person's own that signed in with their account, as its circle says it: online while the
 * Admin heard from it in the last few minutes, else offline since when it was last seen; and an older release than the
 * newest one, while the Admin names that (`latest`; none while it doesn't follow releases). */
const serverState = (listed, latest = null) => ({ server: true, ...(listed.status !== "ready" ? { offline: true } : {}), ...(listed.lastSeenAt ? { lastSeenAt: listed.lastSeenAt } : {}),
  ...(listed.version && latest && olderRelease(listed.version, latest) ? { update: { version: listed.version, latest } } : {}) });

const machineProgress = machine => {
  const setup = machineSetup(machine);
  return setup ? stepProgress(setup.step) : null;
};

/** One saved place as a circle: what it is, its colour (and who may change it from the rail) and how it is doing, from
 * the session's word for it. */
function circle(entry, { activeId, mine, state, offers, waiting }) {
  const connected = state?.status === "connected" ? state : null;
  const listed = connected?.clouds?.find(cloud => cloud.origin === entry.origin) ?? null;
  const kind = entry.origin === mine || entry.home || entry.cloud ? "cloud" : entry.team ? "team" : "server";
  const team = entry.team ? connected?.teams?.find(candidate => candidate?.id === entry.team.id) ?? null : null;
  const machine = entry.origin === mine ? connected?.machine ?? null : team?.machine ?? null;
  const progress = listed ? cloudSetupProgress(listed) : machineProgress(machine);
  const problem = listed ? listed.status === "payment_problem" && listed.role === "owner" : entry.origin === mine && machine?.status === "payment-problem";
  const color = environments.ENTRY_COLORS.includes(entry.color) ? entry.color : listed ? swatchColor(listed.color) : null;
  const paint = railPaint(placeLook(entry, { clouds: connected?.clouds ?? null, homeOrigin: mine }), color);
  return { id: entry.id, name: entry.name, color, kind, opens: true, ...(paint ? { paint } : {}), ...(entry.id === activeId ? { active: true } : {}),
    ...(progress !== null ? { progress } : {}), ...(problem || waiting(entry.id) ? { attention: true } : {}), ...(listed?.managed === false ? serverState(listed, connected?.latestVersion ?? null) : {}),
    ...(kind === "cloud" && cloudState(listed) === "ended" ? { ended: true } : {}), ...(listed?.removable && kind === "cloud" ? { removable: true, cloudId: listed.id } : {}), ...(kind === "server" ? { removable: true, forget: true } : {}),
    ...(kind === "server" && offers(entry) ? { makeHome: true } : {}) };
}

/** My Cloud's saved place that the verified session's list no longer has (its owner removed it from their list, or the Admin
 * lists nothing at its address any more): no circle, unless it is home. An Admin without the list leaves it as it was. */
const unlisted = (entry, { clouds, home, mine }) => Array.isArray(clouds) && (entry.home === true || entry.origin === mine) && entry.origin !== home
  && !clouds.some(cloud => cloud.origin === entry.origin);

/**
 * The circles, in the Server menu's order: This computer while it is a place (before the move made My Cloud home, `home`
 * null), My Cloud, the person's other Clouds and their teams' Clouds in the session's order, then servers saved by hand.
 * A server of theirs that signed in with their account comes next: one saved here already is that saved place (online
 * or not), one not connected to this app yet shows how to connect it, and one only reachable on its own network has
 * nothing to open. A Cloud of theirs the Admin is still making (no address yet) is there too: it opens its own screen (its
 * checkout or its setup), never an address. My Cloud's saved place leaves once the session's list doesn't have it (removed
 * from the list). Fewer than RAIL_MIN_PLACES places in all, This computer counted while it is one: no rail at all, the window
 * as it was, unless the person has a Cloud of their own: then its one circle is the rail, for the + that buys another.
 * `offers(entry)`: a server connected by hand whose menu offers Make … my home (own-home-main.mjs). `waiting(id)`: inbox lines
 * wait in that place, and the window shows another (place-inbox-watch.mjs). `accounts`: the other accounts signed in on this
 * device, each with its places as they were kept: grouped by account after these (withAccounts). `profiles`: the workspaces
 * of this computer's own it may open (local-profiles.mjs; `active`: the one on screen, `settling`: it opens once that bot
 * finishes): first once My Cloud is home, as This computer was, else right after This computer. `computerSettling`: This
 * computer opens once that bot finishes.
 * @param {{ saved: { environments: any[], activeId: string }, home?: string | null, mine?: string | null, state?: any, offers?: (entry: any) => boolean, waiting?: (id: string) => boolean, accounts?: any[], profiles?: Array<{ id: string, name?: string | null, color?: string | null, swatch?: string | null, active?: boolean, settling?: string }>, computerSettling?: string | null }} input
 */
export function railPlaces({ saved, home = null, mine = null, state = null, offers = () => false, waiting = () => false, accounts = [], profiles = [], computerSettling = null }) {
  const connected = state?.status === "connected" ? state : null;
  const clouds = Array.isArray(connected?.clouds) ? connected.clouds : [];
  const order = entry => { const at = clouds.findIndex(cloud => cloud.origin === entry.origin); return at === -1 ? clouds.length : at; };
  const rank = entry => (entry.origin === mine || entry.home ? 0 : environments.listedPlace(entry) ? 1 : 2);
  const remote = saved.environments.filter(entry => entry.kind !== "local" && entry.origin && !(connected && unlisted(entry, { clouds: connected.clouds, home, mine })))
    .map((entry, index) => ({ entry, index }))
    .sort((a, b) => rank(a.entry) - rank(b.entry) || order(a.entry) - order(b.entry) || a.index - b.index)
    .map(({ entry }) => circle(entry, { activeId: saved.activeId, mine, state: connected, offers, waiting }));
  const making = otherOwnClouds(clouds).filter(cloud => !cloud.origin && ["checkout", "setting_up"].includes(cloud.status))
    .map(cloud => ({ id: `${PENDING}${cloud.id}`, name: cloud.name, color: swatchColor(cloud.color), kind: "cloud", opens: true, progress: cloudSetupProgress(cloud) ?? 0,
      ...(cloud.status === "checkout" ? { waiting: true } : {}), cloudId: cloud.id }));
  const savedOrigins = new Set(saved.environments.map(entry => entry.origin).filter(Boolean));
  const servers = signedInServers(clouds).filter(cloud => !cloud.origin || !savedOrigins.has(cloud.origin))
    .map(cloud => ({ id: `${SIGNED_IN}${cloud.id}`, name: cloud.name, color: swatchColor(cloud.color), kind: "server", opens: false, paint: railPaint(listedLook(cloud), swatchColor(cloud.color)),
      ...(cloud.origin ? { pairs: true } : { ownNetwork: true }), ...serverState(cloud, connected?.latestVersion ?? null) }));
  const places = [...remote, ...servers, ...making];
  const computer = environments.savedPlace(saved, environments.LOCAL_ID), color = computer.color ?? null;
  const own = profiles.map(profile => ({ id: `${PROFILE_PLACE}${profile.id}`, name: profile.name || computer.name, color: profile.color ?? null, kind: "computer", profile: profile.id,
    ...(profile.name ? { named: true } : {}), opens: true, paint: { swatch: profile.swatch ?? null }, ...(profile.active ? { active: true } : {}),
    ...(typeof profile.settling === "string" ? { settling: profile.settling } : {}) }));
  const profileShown = own.some(place => place.active);
  const all = home ? [...own, ...places] : [{ id: computer.id, name: computer.name, color, kind: "computer", opens: true, paint: railPaint(placeLook(computer), color),
    ...(saved.activeId === environments.LOCAL_ID && !profileShown ? { active: true } : {}), ...(waiting(computer.id) ? { attention: true } : {}),
    ...(typeof computerSettling === "string" ? { settling: computerSettling } : {}) }, ...own, ...places];
  if (accounts.length) return withAccounts(all, { saved, accounts, email: state?.account?.email ?? null });
  return all.length < (clouds.some(ownCloud) ? 1 : RAIL_MIN_PLACES) ? [] : all;
}

/** What buying another Cloud looks like from a page: no account, address or checkout page, only what the sheet says. */
export function addCloudView(state, offer) {
  const connected = state?.status === "connected" ? state : null;
  if (!connected) return { signedIn: false };
  const clouds = Array.isArray(connected.clouds) ? connected.clouds : null;
  const owned = clouds ? clouds.filter(ownCloud) : [];
  const has = Boolean(connected.machine) || owned.length > 0;
  const home = clouds?.find(cloud => cloud.primary) ?? null;
  const adding = connected.adding ?? null, listed = adding ? clouds?.find(cloud => cloud.id === adding.cloudId) ?? null : null;
  const status = !listed || listed.status === "checkout" ? "checkout" : ["ready", "offline"].includes(listed.status) && listed.origin ? "ready"
    : listed.status === "setting_up" || listed.status === "ready" ? "setting_up" : "problem";
  return {
    signedIn: true, hasCloud: has, supported: Boolean(clouds),
    canAdd: Boolean(clouds) && connected.addCloud?.canAdd !== false,
    ...(connected.addCloud?.canAdd === false ? { blocked: connected.addCloud.blocked } : {}),
    offer: offer ?? null, count: Math.max(owned.length, has ? 1 : 0),
    ...(connected.organizations ? { organizations: { canAdd: connected.organizations.canAdd, count: connected.organizations.count, max: connected.organizations.max,
      ...(connected.organizations.blocked ? { blocked: connected.organizations.blocked } : {}) } } : {}),
    ...(home ? { home: { name: home.name, color: swatchColor(home.color) } } : {}),
    ...(adding ? { adding: { name: adding.name, color: adding.color, tier: adding.tier, startedAt: adding.startedAt, status,
      ...(listed && cloudSetupProgress(listed) !== null ? { progress: cloudSetupProgress(listed) } : {}), ...(adding.link ? { link: true } : {}), ...(adding.done ? { done: true } : {}) } } : {}),
  };
}

/**
 * "“{name}” is ready", once per Cloud this app bought, unless its sheet shows it in the focused window; a click opens it.
 */
export function createAddedCloudNotice({ Notification, focused, sheetOpen, open, shown = () => {}, t = nativeText.englishText.t }) {
  const announced = new Set();
  return state => {
    const adding = state?.status === "connected" ? state.adding : null;
    const listed = adding && !adding.done ? state.clouds?.find(cloud => cloud.id === adding.cloudId) ?? null : null;
    if (!listed || !["ready", "offline"].includes(listed.status) || !listed.origin || announced.has(adding.cloudId)) return false;
    announced.add(adding.cloudId);
    if ((focused() && sheetOpen()) || !Notification.isSupported()) return false;
    const note = new Notification({ title: t("native.cloud.ready.quotedTitle", { name: listed.name }), body: t("native.cloud.ready.open") });
    note.on("click", () => open(adding.cloudId));
    note.show();
    shown();
    return true;
  };
}

const plain = value => (typeof value === "string" ? value : "");
/** A circle as the rail's page sees it: never the Cloud's id or which account main switches to, which only main acts on. */
const seen = ({ cloudId: _cloudId, account: _account, ...place }) => place;
const CHOICES = Object.freeze(["cloud", "link", "address"]);

/**
 * The rail's and +'s channels, each behind main's guard (`guard`: the window's page, or the rail's own). Only the rail's
 * page (`lists`) reads the list and opens or adds a place; +'s buying sheet also runs on a page `trusted` vouches for
 * (the rail's, or this computer's own). Any other page learns only whether the rail stands beside it (`beside`).
 * `places()` is railPlaces' list now; `open(id)` main's own switch; `add` the + sheet's fixed actions; `account()` the
 * MausBot Cloud sign-in; `targets()` the pages told when something changed; `makeHome(id)` starts the move to a server
 * of the person's own (own-home-main.mjs `start`). `switchAccount(id)`: that account becomes the one in use (cloud-accounts.mjs).
 * `openProfile(id)`: the window shows that workspace of this computer's own (local-profiles.mjs `open`). `local`: those
 * workspaces' +, names, colours and removal (local-profiles.mjs), and the end of a switch still waiting when another place opens.
 * `ask(id, run)`: a circle's click, listed at once, `run` told when a newer one came (place-switch.mjs); `switching()`: the place it opens, or null.
 */
export function installCloudRail({ ipcMain, guard, trusted, lists = trusted, beside = () => false, places, open, add, account, clipboard, openCloud, targets, makeHome = null, forget = null, switchAccount = null, openProfile = null, local = null, ask = (_id, run) => run(() => false), switching = () => null, t = nativeText.englishText.t }) {
  let sheetOpen = false, sent = "";
  const allowed = (channel, handler) => guard((event, ...args) => {
    if (!trusted(event)) throw new Error(`${channel} is only available in this app's window`);
    return handler(event, ...args);
  });
  const handle = (channel, handler) => ipcMain.handle(channel, allowed(channel, handler));
  const listed = (channel, handler) => ipcMain.handle(channel, guard((event, ...args) => {
    if (!lists(event)) throw new Error(`${channel} is only available in this app's window`);
    return handler(event, ...args);
  }));
  const view = async () => addCloudView(account()?.state() ?? null, account() ? await account().offer().catch(() => null) : null);
  // +'s choices: main's, and a new organization while this account runs a Team plan's organizations (unless the Admin has
  // turned adding them off).
  const choices = () => { const organizations = account()?.state().organizations; return { ...add.choices(), organization: Boolean(organizations) && organizations.blocked !== "off", local: local?.creatable() === true }; };
  ipcMain.handle("workspaces:places", guard((event) => (lists(event) ? { places: places().map(seen), add: choices(), ...(switching() ? { switching: switching() } : {}) }
    : { places: [], add: { cloud: false, link: false, address: false, organization: false }, ...(beside() ? { beside: true } : {}) })));
  // A Cloud the Admin is still making has no saved place: its own screen opens, by main's word for which Cloud it is.
  const opening = (place, stale) => {
    if (place.id.startsWith(PENDING)) { local?.cancel(); return openCloud(place.cloudId); }
    if (!place.profile) { local?.cancel(); return open(place.id, stale); }
    if (!openProfile) throw new Error(t("native.places.noLongerHere"));
    return openProfile(place.profile, stale);
  };
  // A place of another account: that account becomes the one in use first, and nothing moves if it can't. By then the place is
  // listed as that account's own; an account with no place of its own is simply where the window goes.
  const switchingTo = async (place, stale) => {
    const said = await switchAccount(place.account);
    if (said.outcome !== "switched") return { outcome: said.outcome, ...(said.email ? { email: said.email } : {}) };
    const arrived = places().find(entry => entry.id === place.id && entry.opens && !entry.account);
    if (arrived) await opening(arrived, stale);
    return { outcome: "switched" };
  };
  listed("workspaces:open", (_event, id) => {
    const place = places().find(entry => entry.id === plain(id) && entry.opens);
    if (!place) throw new Error(t("native.places.noLongerHere"));
    return ask(place.id, stale => (place.account ? switchingTo(place, stale) : opening(place, stale)));
  });
  // Remove from list, from a circle's menu (the page asked the person first): only a Cloud or a server saved by hand that
  // main listed as removable. A server is forgotten here; a Cloud is taken off the person's list at the Admin.
  listed("workspaces:remove", async (_event, id) => {
    const place = places().find(entry => entry.id === plain(id) && entry.removable && (entry.cloudId || entry.forget));
    if (!place) throw new Error(t("native.places.noLongerHere"));
    if (place.forget) return { outcome: forget ? ((await forget(place.id)) === false ? "failed" : "removed") : "unsupported" };
    const client = account();
    return { outcome: client?.hideCloud ? (await client.hideCloud(place.cloudId)).outcome : "unsupported" };
  });
  // Make … my home, from a circle's menu: only a server main listed with it, by main's own word for it.
  listed("workspaces:make-home", (_event, id) => {
    const place = places().find(entry => entry.id === plain(id) && entry.makeHome);
    if (!place || !makeHome) throw new Error(t("native.home.notAvailable"));
    return makeHome(place.id);
  });
  // A workspace of this computer's own: a new one from + (on the rail's page, or this computer's own from the Server menu),
  // and a name, a colour or its removal from its circle's menu, only for one main listed.
  handle("workspaces:local-new", (_event, name, color) => {
    if (!local?.creatable()) throw new Error(t("native.places.cannotAdd"));
    const made = local.create({ name: plain(name), color: plain(color) || null });
    if (made.outcome === "made" && openProfile) void Promise.resolve().then(() => openProfile(made.id)).catch(() => {});
    return made;
  });
  const profileListed = id => {
    const place = places().find(entry => entry.id === plain(id) && entry.profile && !entry.account);
    if (!place || !local) throw new Error(t("native.places.noLongerHere"));
    return place.profile;
  };
  listed("workspaces:local-edit", (_event, id, changes) => {
    const profile = profileListed(id);
    if (changes && typeof changes.name === "string") return local.rename(profile, changes.name);
    if (changes && Object.hasOwn(changes, "color")) return local.recolor(profile, changes.color === null ? null : plain(changes.color));
    throw new Error(t("native.places.noLongerHere"));
  });
  listed("workspaces:local-remove", (_event, id) => local.remove(profileListed(id)));
  // The Connect sheet's address (a link, or an address with the code typed apart): main finds the server there first,
  // then connects only to what it found. The sheet runs on the rail's page and in Settings → Servers on this computer's
  // own page, so both channels take a page main vouches for, not only the rail's.
  handle("workspaces:add", (_event, choice, link, code) => {
    if (!CHOICES.includes(choice) || !add.choices()[choice]) throw new Error(t("native.places.cannotAdd"));
    const pasted = choice === "link" ? plain(link).trim().slice(0, 4096) : "";
    return pasted ? add.run(choice, pasted, typeof code === "string" ? code.trim().slice(0, 256) : null) : add.run(choice);
  });
  handle("workspaces:probe", (_event, address, code) => {
    if (!add.choices().link || !add.probe) throw new Error(t("native.places.cannotAdd"));
    return add.probe(plain(address).trim().slice(0, 4096), plain(code).trim().slice(0, 256));
  });
  handle("cloud-add:state", () => view());
  handle("cloud-add:start", async (_event, tier, name, color, source) => {
    const client = account();
    if (!client) throw new Error(t("native.cloud.addSignIn"));
    const plans = (await client.offer().catch(() => null))?.plans ?? [];
    const cleanName = cleanCloudListName(plain(name)), swatch = CLOUD_SWATCHES.includes(color) ? color : null;
    if (!plans.some(plan => plan.tier === plain(tier)) || !cleanName) throw new Error(t("native.cloud.addPlanAndName"));
    const result = await client.addCloud({ tier: plain(tier), interval: "month", name: cleanName, color: swatch, ...(CHECKOUT_SOURCES.includes(source) ? { source } : {}) });
    return result.outcome === "blocked" ? { outcome: "blocked", blocked: result.blocked } : { outcome: result.outcome };
  });
  handle("cloud-add:organization", async (_event, name, color) => {
    const client = account();
    if (!client) throw new Error(t("native.cloud.addSignIn"));
    const cleanName = cleanCloudListName(plain(name)), swatch = CLOUD_SWATCHES.includes(color) ? color : null;
    if (!cleanName) throw new Error(t("native.cloud.addOrganizationName"));
    const result = await client.addOrganization({ name: cleanName, color: swatch });
    return result.outcome === "blocked" ? { outcome: "blocked", blocked: result.blocked } : { outcome: result.outcome };
  });
  handle("cloud-add:reopen", () => account()?.reopenAdd() ?? false);
  handle("cloud-add:copy-link", () => {
    const url = account()?.addLink() ?? null;
    if (url) clipboard.writeText(url);
    return Boolean(url);
  });
  handle("cloud-add:open", async () => {
    const adding = account()?.state().adding;
    if (!adding?.cloudId) throw new Error(t("native.cloud.noNewCloud"));
    await openCloud(adding.cloudId);
    account()?.addDone();
  });
  handle("cloud-add:interest", () => account()?.interest("another_cloud"));
  handle("cloud-add:sheet", (event, on) => {
    sheetOpen = on === true;
    const page = event?.sender;
    if (sheetOpen && typeof page?.once === "function") for (const done of ["did-navigate", "destroyed"]) page.once(done, () => { sheetOpen = false; });
  });
  return {
    sheetOpen: () => sheetOpen,
    /** The places, the sign-in or the rail beside the page changed: the rail's page and the page beside it read them
     * again (a signal only, never the list). */
    changed() {
      const key = JSON.stringify([places(), choices(), account()?.state().adding ?? null, account()?.state().addCloud ?? null, beside(), switching()]);
      if (key === sent) return;
      sent = key;
      for (const target of targets()) if (target && !target.isDestroyed()) target.send("workspaces:changed");
    },
  };
}